From 8edf6d566cecd94d251a4e29ae8c35b77f88d6db Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 9 Jan 2021 01:07:35 +0800 Subject: ... --- FrontEnd/src/app/http/bookmark.ts | 27 +++-- FrontEnd/src/app/http/common.ts | 19 +++- FrontEnd/src/app/http/highlight.ts | 21 ++-- FrontEnd/src/app/http/timeline.ts | 122 ++++++--------------- FrontEnd/src/app/http/token.ts | 2 + FrontEnd/src/app/http/user.ts | 59 ++++------ FrontEnd/src/app/services/timeline.ts | 62 ++++------- FrontEnd/src/app/services/user.ts | 30 +++-- FrontEnd/src/app/views/admin/UserAdmin.tsx | 31 ++---- FrontEnd/src/app/views/home/BoardWithUser.tsx | 12 +- .../views/timeline-common/TimelineCardTemplate.tsx | 10 +- .../views/timeline-common/TimelinePageTemplate.tsx | 62 +++++++++-- 12 files changed, 210 insertions(+), 247 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/http/bookmark.ts b/FrontEnd/src/app/http/bookmark.ts index 68de4d73..15e55d98 100644 --- a/FrontEnd/src/app/http/bookmark.ts +++ b/FrontEnd/src/app/http/bookmark.ts @@ -1,6 +1,5 @@ -import axios from "axios"; - import { + axios, apiBaseUrl, convertToNetworkError, extractResponseData, @@ -18,38 +17,38 @@ export interface HttpHighlightMoveRequest { } export interface IHttpBookmarkClient { - list(token: string): Promise; - put(timeline: string, token: string): Promise; - delete(timeline: string, token: string): Promise; - move(req: HttpHighlightMoveRequest, token: string): Promise; + list(): Promise; + put(timeline: string): Promise; + delete(timeline: string): Promise; + move(req: HttpHighlightMoveRequest): Promise; } export class HttpHighlightClient implements IHttpBookmarkClient { - list(token: string): Promise { + list(): Promise { return axios - .get(`${apiBaseUrl}/bookmarks?token=${token}`) + .get(`${apiBaseUrl}/bookmarks`) .then(extractResponseData) .then((list) => list.map(processRawTimelineInfo)) .catch(convertToNetworkError); } - put(timeline: string, token: string): Promise { + put(timeline: string): Promise { return axios - .put(`${apiBaseUrl}/bookmarks/${timeline}?token=${token}`) + .put(`${apiBaseUrl}/bookmarks/${timeline}`) .catch(convertToNetworkError) .then(); } - delete(timeline: string, token: string): Promise { + delete(timeline: string): Promise { return axios - .delete(`${apiBaseUrl}/bookmarks/${timeline}?token=${token}`) + .delete(`${apiBaseUrl}/bookmarks/${timeline}`) .catch(convertToNetworkError) .then(); } - move(req: HttpHighlightMoveRequest, token: string): Promise { + move(req: HttpHighlightMoveRequest): Promise { return axios - .post(`${apiBaseUrl}/bookmarkop/move?token=${token}`, req) + .post(`${apiBaseUrl}/bookmarkop/move`, req) .catch(convertToNetworkError) .then(); } diff --git a/FrontEnd/src/app/http/common.ts b/FrontEnd/src/app/http/common.ts index 54203d1a..2dd3677b 100644 --- a/FrontEnd/src/app/http/common.ts +++ b/FrontEnd/src/app/http/common.ts @@ -1,7 +1,24 @@ -import { AxiosError, AxiosResponse } from "axios"; +import rawAxios, { AxiosError, AxiosResponse } from "axios"; +import { BehaviorSubject } from "rxjs"; export const apiBaseUrl = "/api"; +export const axios = rawAxios.create(); + +export const tokenSubject: BehaviorSubject = new BehaviorSubject< + string | null +>(null); + +tokenSubject.subscribe((token) => { + if (token == null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + delete axios.defaults.headers.common["Authorization"]; + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; + } +}); + export function base64(blob: Blob): Promise { return new Promise((resolve) => { const reader = new FileReader(); diff --git a/FrontEnd/src/app/http/highlight.ts b/FrontEnd/src/app/http/highlight.ts index 1f226c19..851d52ce 100644 --- a/FrontEnd/src/app/http/highlight.ts +++ b/FrontEnd/src/app/http/highlight.ts @@ -1,6 +1,5 @@ -import axios from "axios"; - import { + axios, apiBaseUrl, convertToNetworkError, extractResponseData, @@ -19,9 +18,9 @@ export interface HttpHighlightMoveRequest { export interface IHttpHighlightClient { list(): Promise; - put(timeline: string, token: string): Promise; - delete(timeline: string, token: string): Promise; - move(req: HttpHighlightMoveRequest, token: string): Promise; + put(timeline: string): Promise; + delete(timeline: string): Promise; + move(req: HttpHighlightMoveRequest): Promise; } export class HttpHighlightClient implements IHttpHighlightClient { @@ -33,23 +32,23 @@ export class HttpHighlightClient implements IHttpHighlightClient { .catch(convertToNetworkError); } - put(timeline: string, token: string): Promise { + put(timeline: string): Promise { return axios - .put(`${apiBaseUrl}/highlights/${timeline}?token=${token}`) + .put(`${apiBaseUrl}/highlights/${timeline}`) .catch(convertToNetworkError) .then(); } - delete(timeline: string, token: string): Promise { + delete(timeline: string): Promise { return axios - .delete(`${apiBaseUrl}/highlights/${timeline}?token=${token}`) + .delete(`${apiBaseUrl}/highlights/${timeline}`) .catch(convertToNetworkError) .then(); } - move(req: HttpHighlightMoveRequest, token: string): Promise { + move(req: HttpHighlightMoveRequest): Promise { return axios - .post(`${apiBaseUrl}/highlightop/move?token=${token}`, req) + .post(`${apiBaseUrl}/highlightop/move`, req) .catch(convertToNetworkError) .then(); } diff --git a/FrontEnd/src/app/http/timeline.ts b/FrontEnd/src/app/http/timeline.ts index 6be0a183..ed02a65b 100644 --- a/FrontEnd/src/app/http/timeline.ts +++ b/FrontEnd/src/app/http/timeline.ts @@ -1,8 +1,9 @@ -import axios, { AxiosError } from "axios"; +import { AxiosError } from "axios"; import { updateQueryString, applyQueryParameters } from "../utilities/url"; import { + axios, apiBaseUrl, extractResponseData, convertToNetworkError, @@ -30,6 +31,8 @@ export interface HttpTimelineInfo { visibility: TimelineVisibility; lastModified: Date; members: HttpUser[]; + isHighlight: boolean; + isBookmark: boolean; } export interface HttpTimelineListQuery { @@ -130,6 +133,8 @@ export interface RawHttpTimelineInfo { visibility: TimelineVisibility; lastModified: string; members: HttpUser[]; + isHighlight: boolean; + isBookmark: boolean; } interface RawTimelinePostTextContent { @@ -229,33 +234,17 @@ export interface IHttpTimelineClient { ifModifiedSince: Date; } ): Promise; - postTimeline( - req: HttpTimelinePostRequest, - token: string - ): Promise; + postTimeline(req: HttpTimelinePostRequest): Promise; patchTimeline( timelineName: string, - req: HttpTimelinePatchRequest, - token: string + req: HttpTimelinePatchRequest ): Promise; - deleteTimeline(timelineName: string, token: string): Promise; - memberPut( - timelineName: string, - username: string, - token: string - ): Promise; - memberDelete( - timelineName: string, - username: string, - token: string - ): Promise; + deleteTimeline(timelineName: string): Promise; + memberPut(timelineName: string, username: string): Promise; + memberDelete(timelineName: string, username: string): Promise; + listPost(timelineName: string): Promise; listPost( timelineName: string, - token?: string - ): Promise; - listPost( - timelineName: string, - token: string | undefined, query: { modifiedSince?: Date; includeDeleted?: false; @@ -263,33 +252,22 @@ export interface IHttpTimelineClient { ): Promise; listPost( timelineName: string, - token: string | undefined, query: { modifiedSince?: Date; includeDeleted: true; } ): Promise; + getPostData(timelineName: string, postId: number): Promise; getPostData( timelineName: string, postId: number, - token?: string - ): Promise; - getPostData( - timelineName: string, - postId: number, - token: string | undefined, etag: string ): Promise; postPost( timelineName: string, - req: HttpTimelinePostPostRequest, - token: string + req: HttpTimelinePostPostRequest ): Promise; - deletePost( - timelineName: string, - postId: number, - token: string - ): Promise; + deletePost(timelineName: string, postId: number): Promise; } export class HttpTimelineClient implements IHttpTimelineClient { @@ -339,12 +317,9 @@ export class HttpTimelineClient implements IHttpTimelineClient { .catch(convertToNetworkError); } - postTimeline( - req: HttpTimelinePostRequest, - token: string - ): Promise { + postTimeline(req: HttpTimelinePostRequest): Promise { return axios - .post(`${apiBaseUrl}/timelines?token=${token}`, req) + .post(`${apiBaseUrl}/timelines`, req) .then(extractResponseData) .then(processRawTimelineInfo) .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError)) @@ -353,12 +328,11 @@ export class HttpTimelineClient implements IHttpTimelineClient { patchTimeline( timelineName: string, - req: HttpTimelinePatchRequest, - token: string + req: HttpTimelinePatchRequest ): Promise { return axios .patch( - `${apiBaseUrl}/timelines/${timelineName}?token=${token}`, + `${apiBaseUrl}/timelines/${timelineName}`, req ) .then(extractResponseData) @@ -366,46 +340,30 @@ export class HttpTimelineClient implements IHttpTimelineClient { .catch(convertToNetworkError); } - deleteTimeline(timelineName: string, token: string): Promise { + deleteTimeline(timelineName: string): Promise { return axios - .delete(`${apiBaseUrl}/timelines/${timelineName}?token=${token}`) + .delete(`${apiBaseUrl}/timelines/${timelineName}`) .catch(convertToNetworkError) .then(); } - memberPut( - timelineName: string, - username: string, - token: string - ): Promise { + memberPut(timelineName: string, username: string): Promise { return axios - .put( - `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}` - ) + .put(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`) .catch(convertToNetworkError) .then(); } - memberDelete( - timelineName: string, - username: string, - token: string - ): Promise { + memberDelete(timelineName: string, username: string): Promise { return axios - .delete( - `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}` - ) + .delete(`${apiBaseUrl}/timelines/${timelineName}/members/${username}`) .catch(convertToNetworkError) .then(); } + listPost(timelineName: string): Promise; listPost( timelineName: string, - token?: string - ): Promise; - listPost( - timelineName: string, - token: string | undefined, query: { modifiedSince?: Date; includeDeleted?: false; @@ -413,7 +371,6 @@ export class HttpTimelineClient implements IHttpTimelineClient { ): Promise; listPost( timelineName: string, - token: string | undefined, query: { modifiedSince?: Date; includeDeleted: true; @@ -421,14 +378,12 @@ export class HttpTimelineClient implements IHttpTimelineClient { ): Promise; listPost( timelineName: string, - token?: string, query?: { modifiedSince?: Date; includeDeleted?: boolean; } ): Promise { let url = `${apiBaseUrl}/timelines/${timelineName}/posts`; - url = updateQueryString("token", token, url); if (query != null) { if (query.modifiedSince != null) { url = updateQueryString( @@ -457,15 +412,10 @@ export class HttpTimelineClient implements IHttpTimelineClient { ); } + getPostData(timelineName: string, postId: number): Promise; getPostData( timelineName: string, postId: number, - token: string - ): Promise; - getPostData( - timelineName: string, - postId: number, - token?: string, etag?: string ): Promise { const headers = @@ -475,8 +425,7 @@ export class HttpTimelineClient implements IHttpTimelineClient { } : undefined; - let url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`; - url = updateQueryString("token", token, url); + const url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`; return axios .get(url, { @@ -491,8 +440,7 @@ export class HttpTimelineClient implements IHttpTimelineClient { async postPost( timelineName: string, - req: HttpTimelinePostPostRequest, - token: string + req: HttpTimelinePostPostRequest ): Promise { let content: RawTimelinePostPostRequestContent; if (req.content.type === "image") { @@ -512,7 +460,7 @@ export class HttpTimelineClient implements IHttpTimelineClient { } return await axios .post( - `${apiBaseUrl}/timelines/${timelineName}/posts?token=${token}`, + `${apiBaseUrl}/timelines/${timelineName}/posts`, rawReq ) .then(extractResponseData) @@ -520,15 +468,9 @@ export class HttpTimelineClient implements IHttpTimelineClient { .then((rawPost) => processRawTimelinePostInfo(rawPost)); } - deletePost( - timelineName: string, - postId: number, - token: string - ): Promise { + deletePost(timelineName: string, postId: number): Promise { return axios - .delete( - `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}?token=${token}` - ) + .delete(`${apiBaseUrl}/timelines/${timelineName}/posts/${postId}`) .catch(convertToNetworkError) .then(); } diff --git a/FrontEnd/src/app/http/token.ts b/FrontEnd/src/app/http/token.ts index ae0cf3f6..c0644515 100644 --- a/FrontEnd/src/app/http/token.ts +++ b/FrontEnd/src/app/http/token.ts @@ -1,3 +1,5 @@ +// Don't use axios in common because it will contains +// authorization header, which shouldn't be used in token apis. import axios, { AxiosError } from "axios"; import { diff --git a/FrontEnd/src/app/http/user.ts b/FrontEnd/src/app/http/user.ts index 929956d0..8345880e 100644 --- a/FrontEnd/src/app/http/user.ts +++ b/FrontEnd/src/app/http/user.ts @@ -1,6 +1,7 @@ -import axios, { AxiosError } from "axios"; +import { AxiosError } from "axios"; import { + axios, apiBaseUrl, convertToNetworkError, extractResponseData, @@ -62,28 +63,22 @@ export class HttpChangePasswordBadCredentialError extends Error { export interface IHttpUserClient { list(): Promise; get(username: string): Promise; - patch( - username: string, - req: HttpUserPatchRequest, - token: string - ): Promise; - delete(username: string, token: string): Promise; + patch(username: string, req: HttpUserPatchRequest): Promise; + delete(username: string): Promise; getAvatar(username: string): Promise; getAvatar( username: string, etag: string ): Promise; - putAvatar(username: string, data: Blob, token: string): Promise; - changePassword(req: HttpChangePasswordRequest, token: string): Promise; + putAvatar(username: string, data: Blob): Promise; + changePassword(req: HttpChangePasswordRequest): Promise; putUserPermission( username: string, - permission: UserPermission, - token: string + permission: UserPermission ): Promise; deleteUserPermission( username: string, - permission: UserPermission, - token: string + permission: UserPermission ): Promise; createUser(req: HttpCreateUserRequest, token: string): Promise; @@ -105,20 +100,16 @@ export class HttpUserClient implements IHttpUserClient { .catch(convertToNetworkError); } - patch( - username: string, - req: HttpUserPatchRequest, - token: string - ): Promise { + patch(username: string, req: HttpUserPatchRequest): Promise { return axios - .patch(`${apiBaseUrl}/users/${username}?token=${token}`, req) + .patch(`${apiBaseUrl}/users/${username}`, req) .then(extractResponseData) .catch(convertToNetworkError); } - delete(username: string, token: string): Promise { + delete(username: string): Promise { return axios - .delete(`${apiBaseUrl}/users/${username}?token=${token}`) + .delete(`${apiBaseUrl}/users/${username}`) .catch(convertToNetworkError) .then(); } @@ -146,9 +137,9 @@ export class HttpUserClient implements IHttpUserClient { .catch(convertToNetworkError); } - putAvatar(username: string, data: Blob, token: string): Promise { + putAvatar(username: string, data: Blob): Promise { return axios - .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, { + .put(`${apiBaseUrl}/users/${username}/avatar`, data, { headers: { "Content-Type": data.type, }, @@ -157,9 +148,9 @@ export class HttpUserClient implements IHttpUserClient { .then(); } - changePassword(req: HttpChangePasswordRequest, token: string): Promise { + changePassword(req: HttpChangePasswordRequest): Promise { return axios - .post(`${apiBaseUrl}/userop/changepassword?token=${token}`, req) + .post(`${apiBaseUrl}/userop/changepassword`, req) .catch( convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError) ) @@ -169,33 +160,27 @@ export class HttpUserClient implements IHttpUserClient { putUserPermission( username: string, - permission: UserPermission, - token: string + permission: UserPermission ): Promise { return axios - .put( - `${apiBaseUrl}/users/${username}/permissions/${permission}?token=${token}` - ) + .put(`${apiBaseUrl}/users/${username}/permissions/${permission}`) .catch(convertToNetworkError) .then(); } deleteUserPermission( username: string, - permission: UserPermission, - token: string + permission: UserPermission ): Promise { return axios - .delete( - `${apiBaseUrl}/users/${username}/permissions/${permission}?token=${token}` - ) + .delete(`${apiBaseUrl}/users/${username}/permissions/${permission}`) .catch(convertToNetworkError) .then(); } - createUser(req: HttpCreateUserRequest, token: string): Promise { + createUser(req: HttpCreateUserRequest): Promise { return axios - .post(`${apiBaseUrl}/userop/createuser?token=${token}`, req) + .post(`${apiBaseUrl}/userop/createuser`, req) .then(extractResponseData) .catch(convertToNetworkError) .then(); diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts index c58516fc..3b9a9072 100644 --- a/FrontEnd/src/app/services/timeline.ts +++ b/FrontEnd/src/app/services/timeline.ts @@ -28,13 +28,7 @@ export type { TimelineVisibility } from "@/http/timeline"; import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common"; import { DataHub, WithSyncStatus } from "./DataHub"; -import { - checkLogin, - userService, - userInfoService, - User, - AuthUser, -} from "./user"; +import { userInfoService, User, AuthUser } from "./user"; export type TimelineInfo = HttpTimelineInfo; export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; @@ -227,14 +221,10 @@ export class TimelineService { } createTimeline(timelineName: string): Observable { - const user = checkLogin(); return from( - getHttpTimelineClient().postTimeline( - { - name: timelineName, - }, - user.token - ) + getHttpTimelineClient().postTimeline({ + name: timelineName, + }) ).pipe( convertError(HttpTimelineNameConflictError, TimelineNameConflictError) ); @@ -244,10 +234,9 @@ export class TimelineService { timelineName: string, req: TimelineChangePropertyRequest ): Observable { - const user = checkLogin(); return from( getHttpTimelineClient() - .patchTimeline(timelineName, req, user.token) + .patchTimeline(timelineName, req) .then((timeline) => { void this.syncTimeline(timelineName); return timeline; @@ -256,17 +245,13 @@ export class TimelineService { } deleteTimeline(timelineName: string): Observable { - const user = checkLogin(); - return from( - getHttpTimelineClient().deleteTimeline(timelineName, user.token) - ); + return from(getHttpTimelineClient().deleteTimeline(timelineName)); } addMember(timelineName: string, username: string): Observable { - const user = checkLogin(); return from( getHttpTimelineClient() - .memberPut(timelineName, username, user.token) + .memberPut(timelineName, username) .then(() => { void this.syncTimeline(timelineName); }) @@ -274,10 +259,9 @@ export class TimelineService { } removeMember(timelineName: string, username: string): Observable { - const user = checkLogin(); return from( getHttpTimelineClient() - .memberDelete(timelineName, username, user.token) + .memberDelete(timelineName, username) .then(() => { void this.syncTimeline(timelineName); }) @@ -344,10 +328,7 @@ export class TimelineService { try { if (lastUpdatedTime == null) { - const httpPosts = await getHttpTimelineClient().listPost( - key, - userService.currentUser?.token - ); + const httpPosts = await getHttpTimelineClient().listPost(key); userInfoService.saveUsers( uniqBy( @@ -362,14 +343,10 @@ export class TimelineService { line.next({ type: "synced", posts }); } else { - const httpPosts = await getHttpTimelineClient().listPost( - key, - userService.currentUser?.token, - { - modifiedSince: lastUpdatedTime, - includeDeleted: true, - } - ); + const httpPosts = await getHttpTimelineClient().listPost(key, { + modifiedSince: lastUpdatedTime, + includeDeleted: true, + }); const deletedIds = httpPosts .filter((p) => p.deleted) @@ -582,10 +559,9 @@ export class TimelineService { timelineName: string, request: TimelineCreatePostRequest ): Observable { - const user = checkLogin(); return from( getHttpTimelineClient() - .postPost(timelineName, request, user.token) + .postPost(timelineName, request) .then(() => { void this.syncPosts(timelineName); }) @@ -593,10 +569,9 @@ export class TimelineService { } deletePost(timelineName: string, postId: number): Observable { - const user = checkLogin(); return from( getHttpTimelineClient() - .deletePost(timelineName, postId, user.token) + .deletePost(timelineName, postId) .then(() => { void this.syncPosts(timelineName); }) @@ -681,7 +656,10 @@ export function validateTimelineName(name: string): boolean { export function useTimelineInfo( timelineName: string -): TimelineWithSyncStatus | undefined { +): [ + TimelineWithSyncStatus | undefined, + React.Dispatch> +] { const [state, setState] = React.useState( undefined ); @@ -695,7 +673,7 @@ export function useTimelineInfo( subscription.unsubscribe(); }; }, [timelineName]); - return state; + return [state, setState]; } export function usePostList( diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts index 7a60b474..c3343493 100644 --- a/FrontEnd/src/app/services/user.ts +++ b/FrontEnd/src/app/services/user.ts @@ -5,7 +5,12 @@ import { map, filter } from "rxjs/operators"; import { UiLogicError } from "@/common"; import { convertError } from "@/utilities/rxjs"; -import { HttpNetworkError, BlobWithEtag, NotModified } from "@/http/common"; +import { + HttpNetworkError, + BlobWithEtag, + NotModified, + tokenSubject, +} from "@/http/common"; import { getHttpTokenClient, HttpCreateTokenBadCredentialError, @@ -61,6 +66,12 @@ export class BadCredentialError { const USER_STORAGE_KEY = "currentuser"; export class UserService { + constructor() { + this.userSubject.subscribe((u) => { + tokenSubject.next(u?.token ?? null); + }); + } + private userSubject = new BehaviorSubject( undefined ); @@ -167,13 +178,10 @@ export class UserService { throw new UiLogicError("Not login or checked now, can't log out."); } const $ = from( - getHttpUserClient().changePassword( - { - oldPassword, - newPassword, - }, - this.currentUser.token - ) + getHttpUserClient().changePassword({ + oldPassword, + newPassword, + }) ); $.subscribe(() => { void this.logout(); @@ -378,15 +386,13 @@ export class UserInfoService { } async setAvatar(username: string, blob: Blob): Promise { - const user = checkLogin(); - await getHttpUserClient().putAvatar(username, blob, user.token); + await getHttpUserClient().putAvatar(username, blob); this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" }); } async setNickname(username: string, nickname: string): Promise { - const user = checkLogin(); return getHttpUserClient() - .patch(username, { nickname }, user.token) + .patch(username, { nickname }) .then((user) => { this.saveUser(user); }); diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx index d66abbec..fbdfd5a3 100644 --- a/FrontEnd/src/app/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx @@ -62,7 +62,7 @@ const UsernameLabel: React.FC = (props) => { const UserDeleteDialog: React.FC< DialogProps<{ username: string }, unknown> -> = ({ open, close, token, data: { username }, onSuccess }) => { +> = ({ open, close, data: { username }, onSuccess }) => { return ( {username}2 )} - onProcess={() => getHttpUserClient().delete(username, token)} + onProcess={() => getHttpUserClient().delete(username)} onSuccessAndClose={onSuccess} /> ); @@ -87,7 +87,7 @@ const UserModifyDialog: React.FC< }, HttpUser > -> = ({ open, close, token, data: { oldUser }, onSuccess }) => { +> = ({ open, close, data: { oldUser }, onSuccess }) => { return ( - getHttpUserClient().patch( - oldUser.username, - { - username: username !== oldUser.username ? username : undefined, - password: password !== "" ? password : undefined, - nickname: nickname !== oldUser.nickname ? nickname : undefined, - }, - token - ) + getHttpUserClient().patch(oldUser.username, { + username: username !== oldUser.username ? username : undefined, + password: password !== "" ? password : undefined, + nickname: nickname !== oldUser.nickname ? nickname : undefined, + }) } onSuccessAndClose={onSuccess} /> @@ -138,7 +134,7 @@ const UserPermissionModifyDialog: React.FC< }, UserPermission[] > -> = ({ open, close, token, data: { username, permissions }, onSuccess }) => { +> = ({ open, close, data: { username, permissions }, onSuccess }) => { const oldPermissionBoolList: boolean[] = kUserPermissionList.map( (permission) => permissions.includes(permission) ); @@ -168,16 +164,11 @@ const UserPermissionModifyDialog: React.FC< const permission = kUserPermissionList[index]; if (oldValue === newValue) continue; if (newValue) { - await getHttpUserClient().putUserPermission( - username, - permission, - token - ); + await getHttpUserClient().putUserPermission(username, permission); } else { await getHttpUserClient().deleteUserPermission( username, - permission, - token + permission ); } } diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx index 8afe440b..ba22916c 100644 --- a/FrontEnd/src/app/views/home/BoardWithUser.tsx +++ b/FrontEnd/src/app/views/home/BoardWithUser.tsx @@ -20,11 +20,11 @@ const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => { getHttpBookmarkClient().list(user.token)} + load={() => getHttpBookmarkClient().list()} editHandler={{ onDelete: (timeline) => { return getHttpBookmarkClient() - .delete(timeline, user.token) + .delete(timeline) .catch((e) => { pushAlert({ message: { @@ -39,8 +39,7 @@ const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => { onMove: (timeline, index, offset) => { return getHttpBookmarkClient() .move( - { timeline, newPosition: index + offset + 1 }, // +1 because backend contract: index starts at 1 - user.token + { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1 ) .catch((e) => { pushAlert({ @@ -75,7 +74,7 @@ const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => { ? { onDelete: (timeline) => { return getHttpHighlightClient() - .delete(timeline, user.token) + .delete(timeline) .catch((e) => { pushAlert({ message: { @@ -90,8 +89,7 @@ const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => { onMove: (timeline, index, offset) => { return getHttpHighlightClient() .move( - { timeline, newPosition: index + offset + 1 }, // +1 because backend contract: index starts at 1 - user.token + { timeline, newPosition: index + offset + 1 } // +1 because backend contract: index starts at 1 ) .catch((e) => { pushAlert({ diff --git a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx index ece1942f..b2b349bc 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx @@ -56,13 +56,19 @@ function TimelineCardTemplate({
{onHighlight != null ? ( ) : null} {onBookmark != null ? ( ) : null} diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index 7f5c8206..caced3b7 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -45,7 +45,7 @@ export default function TimelinePageTemplate( null ); - const timelineState = useTimelineInfo(name); + const [timelineState, setTimelineState] = useTimelineInfo(name); const postListState = usePostList(name); const onPost: TimelinePostSendCallback = React.useCallback( @@ -121,24 +121,64 @@ export default function TimelinePageTemplate( onBookmark: user != null ? () => { - void getHttpBookmarkClient() - .put(name, user.token) - .then(() => { - pushAlert({ - message: { - type: "i18n", - key: "timeline.addBookmarkSuccess", + if (timeline.isBookmark) { + setTimelineState({ + ...timelineState, + timeline: { + ...timeline, + isBookmark: false, + }, + }); + void getHttpBookmarkClient() + .delete(name) + .then( + () => { + void timelineService.syncTimeline(name); }, - type: "success", - }); + () => { + pushAlert({ + message: { + type: "i18n", + key: "timeline.removeBookmarkFail", + }, + type: "danger", + }); + setTimelineState(timelineState); + } + ); + } else { + setTimelineState({ + ...timelineState, + timeline: { + ...timeline, + isBookmark: true, + }, }); + void getHttpBookmarkClient() + .put(name) + .then( + () => { + void timelineService.syncTimeline(name); + }, + () => { + pushAlert({ + message: { + type: "i18n", + key: "timeline.addBookmarkFail", + }, + type: "danger", + }); + setTimelineState(timelineState); + } + ); + } } : undefined, onHighlight: user != null && user.hasHighlightTimelineAdministrationPermission ? () => { void getHttpHighlightClient() - .put(name, user.token) + .put(name) .then(() => { pushAlert({ message: { -- cgit v1.2.3 From 109deb21f406ac0a9bd49ca7fd87a1ad844050ba Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 9 Jan 2021 01:13:29 +0800 Subject: ... --- .../views/timeline-common/TimelinePageTemplate.tsx | 78 ++++++++-------------- 1 file changed, 28 insertions(+), 50 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index caced3b7..e0ad002a 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -121,57 +121,35 @@ export default function TimelinePageTemplate( onBookmark: user != null ? () => { - if (timeline.isBookmark) { - setTimelineState({ - ...timelineState, - timeline: { - ...timeline, - isBookmark: false, - }, - }); - void getHttpBookmarkClient() - .delete(name) - .then( - () => { - void timelineService.syncTimeline(name); - }, - () => { - pushAlert({ - message: { - type: "i18n", - key: "timeline.removeBookmarkFail", - }, - type: "danger", - }); - setTimelineState(timelineState); - } - ); - } else { - setTimelineState({ - ...timelineState, - timeline: { - ...timeline, - isBookmark: true, - }, - }); - void getHttpBookmarkClient() - .put(name) - .then( - () => { - void timelineService.syncTimeline(name); + const { isBookmark } = timeline; + setTimelineState({ + ...timelineState, + timeline: { + ...timeline, + isBookmark: !isBookmark, + }, + }); + const client = getHttpBookmarkClient(); + const promise = isBookmark + ? client.delete(name) + : client.put(name); + promise.then( + () => { + void timelineService.syncTimeline(name); + }, + () => { + pushAlert({ + message: { + type: "i18n", + key: isBookmark + ? "timeline.removeBookmarkFail" + : "timeline.addBookmarkFail", // TODO: Add this translation. }, - () => { - pushAlert({ - message: { - type: "i18n", - key: "timeline.addBookmarkFail", - }, - type: "danger", - }); - setTimelineState(timelineState); - } - ); - } + type: "danger", + }); + setTimelineState(timelineState); + } + ); } : undefined, onHighlight: -- cgit v1.2.3 From d3cd08d6234332a6f79bdcb080d490c38741e0e0 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 9 Jan 2021 17:19:29 +0800 Subject: ... --- FrontEnd/src/app/http/timeline.ts | 27 ++++++--------------- FrontEnd/src/app/utilities/url.ts | 50 ++++++--------------------------------- 2 files changed, 14 insertions(+), 63 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/http/timeline.ts b/FrontEnd/src/app/http/timeline.ts index ed02a65b..228b6105 100644 --- a/FrontEnd/src/app/http/timeline.ts +++ b/FrontEnd/src/app/http/timeline.ts @@ -1,6 +1,6 @@ import { AxiosError } from "axios"; -import { updateQueryString, applyQueryParameters } from "../utilities/url"; +import { applyQueryParameters } from "../utilities/url"; import { axios, @@ -383,26 +383,13 @@ export class HttpTimelineClient implements IHttpTimelineClient { includeDeleted?: boolean; } ): Promise { - let url = `${apiBaseUrl}/timelines/${timelineName}/posts`; - if (query != null) { - if (query.modifiedSince != null) { - url = updateQueryString( - "modifiedSince", - query.modifiedSince.toISOString(), - url - ); - } - if (query.includeDeleted != null) { - url = updateQueryString( - "includeDeleted", - query.includeDeleted ? "true" : "false", - url - ); - } - } - return axios - .get(url) + .get( + applyQueryParameters( + `${apiBaseUrl}/timelines/${timelineName}/posts`, + query + ) + ) .then(extractResponseData) .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError)) .catch(convertToForbiddenError) diff --git a/FrontEnd/src/app/utilities/url.ts b/FrontEnd/src/app/utilities/url.ts index 17ead5b2..21ad6304 100644 --- a/FrontEnd/src/app/utilities/url.ts +++ b/FrontEnd/src/app/utilities/url.ts @@ -1,52 +1,16 @@ -//copied from https://stackoverflow.com/questions/5999118/how-can-i-add-or-update-a-query-string-parameter -export function updateQueryString( - key: string, - value: undefined | string | null, - url: string -): string { - const re = new RegExp("([?&])" + key + "=.*?(&|#|$)(.*)", "gi"); - let hash; - - if (re.test(url)) { - if (typeof value !== "undefined" && value !== null) { - return url.replace(re, "$1" + key + "=" + value + "$2$3"); - } else { - hash = url.split("#"); - url = hash[0].replace(re, "$1$3").replace(/(&|\?)$/, ""); - if (typeof hash[1] !== "undefined" && hash[1] !== null) { - url += "#" + hash[1]; - } - return url; - } - } else { - if (typeof value !== "undefined" && value !== null) { - const separator = url.includes("?") ? "&" : "?"; - hash = url.split("#"); - url = hash[0] + separator + key + "=" + value; - if (typeof hash[1] !== "undefined" && hash[1] !== null) { - url += "#" + hash[1]; - } - return url; - } else { - return url; - } - } -} - export function applyQueryParameters(url: string, query: T): string { if (query == null) return url; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { - if (typeof value === "string") url = updateQueryString(key, value, url); - else if (typeof value === "number") - url = updateQueryString(key, String(value), url); - else if (typeof value === "boolean") - url = updateQueryString(key, value ? "true" : "false", url); - else if (value instanceof Date) - url = updateQueryString(key, value.toISOString(), url); + if (typeof value === "string") params.set(key, value); + else if (typeof value === "number") params.set(key, String(value)); + else if (typeof value === "boolean") params.set(key, String(value)); + else if (value instanceof Date) params.set(key, value.toISOString()); else { console.error("Unknown query parameter type. Param: ", value); } } - return url; + return url + "?" + params.toString(); } -- cgit v1.2.3 From f704d13bb67a20d4009b4b990ec2942b50849431 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 9 Jan 2021 23:25:37 +0800 Subject: ... --- FrontEnd/src/app/http/common.ts | 15 +++++++++------ FrontEnd/src/app/services/user.ts | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/http/common.ts b/FrontEnd/src/app/http/common.ts index 2dd3677b..95d29fb6 100644 --- a/FrontEnd/src/app/http/common.ts +++ b/FrontEnd/src/app/http/common.ts @@ -1,15 +1,18 @@ import rawAxios, { AxiosError, AxiosResponse } from "axios"; -import { BehaviorSubject } from "rxjs"; export const apiBaseUrl = "/api"; export const axios = rawAxios.create(); -export const tokenSubject: BehaviorSubject = new BehaviorSubject< - string | null ->(null); +let _token: string | null = null; + +export function getHttpToken(): string | null { + return _token; +} + +export function setHttpToken(token: string | null): void { + _token = token; -tokenSubject.subscribe((token) => { if (token == null) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access delete axios.defaults.headers.common["Authorization"]; @@ -17,7 +20,7 @@ tokenSubject.subscribe((token) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; } -}); +} export function base64(blob: Blob): Promise { return new Promise((resolve) => { diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts index c3343493..f08802a5 100644 --- a/FrontEnd/src/app/services/user.ts +++ b/FrontEnd/src/app/services/user.ts @@ -9,7 +9,7 @@ import { HttpNetworkError, BlobWithEtag, NotModified, - tokenSubject, + setHttpToken, } from "@/http/common"; import { getHttpTokenClient, @@ -68,7 +68,7 @@ const USER_STORAGE_KEY = "currentuser"; export class UserService { constructor() { this.userSubject.subscribe((u) => { - tokenSubject.next(u?.token ?? null); + setHttpToken(u?.token ?? null); }); } -- cgit v1.2.3 From 9d1d88bacf121699ddf085b799ce191c0d7d3a50 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 9 Jan 2021 23:30:39 +0800 Subject: ... --- FrontEnd/src/app/locales/en/translation.json | 6 ++-- FrontEnd/src/app/locales/zh/translation.json | 6 ++-- .../views/timeline-common/TimelinePageTemplate.tsx | 32 +++++++++++++++++----- 3 files changed, 33 insertions(+), 11 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json index 596b5217..ebf9552a 100644 --- a/FrontEnd/src/app/locales/en/translation.json +++ b/FrontEnd/src/app/locales/en/translation.json @@ -107,8 +107,10 @@ "prompt": "Are you sure to delete the post? This operation is not recoverable." } }, - "addHighlightSuccess": "Succeeded to add highlight.", - "addBookmarkSuccess": "Succeeded to add bookmark." + "addHighlightFail": "Failed to add highlight.", + "removeHighlightFail": "Failed to remove highlight.", + "addBookmarkFail": "Failed to add bookmark.", + "removeBookmarkFail": "Failed to remove bookmark." }, "user": { "username": "username", diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json index e15e177e..0d063b4e 100644 --- a/FrontEnd/src/app/locales/zh/translation.json +++ b/FrontEnd/src/app/locales/zh/translation.json @@ -107,8 +107,10 @@ "prompt": "确定删除这个消息?这个操作不可撤销。" } }, - "addHighlightSuccess": "成功添加高光。", - "addBookmarkSuccess": "成功添加书签。" + "addHighlightFail": "添加高光失败。", + "removeHighlightFail": "删除高光失败。", + "addBookmarkFail": "添加书签失败。", + "removeBookmarkFail": "删除书签失败。" }, "user": { "username": "用户名", diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index e0ad002a..35b31ec2 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -143,7 +143,7 @@ export default function TimelinePageTemplate( type: "i18n", key: isBookmark ? "timeline.removeBookmarkFail" - : "timeline.addBookmarkFail", // TODO: Add this translation. + : "timeline.addBookmarkFail", }, type: "danger", }); @@ -155,17 +155,35 @@ export default function TimelinePageTemplate( onHighlight: user != null && user.hasHighlightTimelineAdministrationPermission ? () => { - void getHttpHighlightClient() - .put(name) - .then(() => { + const { isHighlight } = timeline; + setTimelineState({ + ...timelineState, + timeline: { + ...timeline, + isHighlight: !isHighlight, + }, + }); + const client = getHttpHighlightClient(); + const promise = isHighlight + ? client.delete(name) + : client.put(name); + promise.then( + () => { + void timelineService.syncTimeline(name); + }, + () => { pushAlert({ message: { type: "i18n", - key: "timeline.addHighlightSuccess", + key: isHighlight + ? "timeline.removeHighlightFail" + : "timeline.addHighlightFail", }, - type: "success", + type: "danger", }); - }); + setTimelineState(timelineState); + } + ); } : undefined, }; -- cgit v1.2.3 From 5cb8f773183a46b7be6f0af14110a499432abba7 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 9 Jan 2021 23:44:45 +0800 Subject: ... --- .../views/timeline-common/TimelineCardTemplate.tsx | 10 +++++-- .../views/timeline-common/TimelinePageTemplate.tsx | 14 ++++----- .../timeline-common/TimelinePageTemplateUI.tsx | 35 +++++++++++----------- .../src/app/views/timeline/TimelineInfoCard.tsx | 6 ++-- FrontEnd/src/app/views/user/UserInfoCard.tsx | 6 ++-- 5 files changed, 38 insertions(+), 33 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx index b2b349bc..e62f76fa 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx @@ -10,7 +10,11 @@ import SyncStatusBadge from "../timeline-common/SyncStatusBadge"; import CollapseButton from "../timeline-common/CollapseButton"; export interface TimelineCardTemplateProps - extends Omit, "onManage" | "onMember"> { + extends Omit, "operations"> { + operations: Pick< + TimelineCardComponentProps<"">["operations"], + "onHighlight" | "onBookmark" + >; infoArea: React.ReactElement; manageArea: | { type: "member"; onMember: () => void } @@ -33,13 +37,13 @@ function TimelineCardTemplate({ collapse, infoArea, manageArea, - onBookmark, - onHighlight, + operations, toggleCollapse, syncStatus, className, }: TimelineCardTemplateProps): React.ReactElement | null { const { t } = useTranslation(); + const { onBookmark, onHighlight } = operations; return (
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index 35b31ec2..f66d14e0 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -110,7 +110,7 @@ export default function TimelinePageTemplate( : undefined, })); - const others = { + const operations = { onPost: service.hasPostPermission(user, timeline) ? onPost : undefined, @@ -189,20 +189,20 @@ export default function TimelinePageTemplate( }; if (type === "cache") { - return [{ timeline, posts, ...others }, "syncing"]; + return [{ timeline, posts, operations }, "syncing"]; } else if (type === "offline") { - return [{ timeline, posts, ...others }, "offline"]; + return [{ timeline, posts, operations }, "offline"]; } else { if (postListState == null) { - return [{ timeline, posts, ...others }, "syncing"]; + return [{ timeline, posts, operations }, "syncing"]; } else { const { type: postListType } = postListState; if (postListType === "synced") { - return [{ timeline, posts, ...others }, "synced"]; + return [{ timeline, posts, operations }, "synced"]; } else if (postListType === "cache") { - return [{ timeline, posts, ...others }, "syncing"]; + return [{ timeline, posts, operations }, "syncing"]; } else if (postListType === "offline") { - return [{ timeline, posts, ...others }, "offline"]; + return [{ timeline, posts, operations }, "offline"]; } } } diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx index 20ec6e43..b2824c84 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx @@ -13,14 +13,16 @@ import { TimelineSyncStatus } from "./SyncStatusBadge"; export interface TimelineCardComponentProps { timeline: TimelineInfo; - onManage?: (item: TManageItems | "property") => void; - onMember: () => void; - onBookmark?: () => void; - onHighlight?: () => void; - className?: string; - collapse: boolean; syncStatus: TimelineSyncStatus; + operations: { + onManage?: (item: TManageItems | "property") => void; + onMember: () => void; + onBookmark?: () => void; + onHighlight?: () => void; + }; + collapse: boolean; toggleCollapse: () => void; + className?: string; } export interface TimelinePageTemplateUIProps { @@ -28,11 +30,13 @@ export interface TimelinePageTemplateUIProps { | { timeline: TimelineInfo; posts?: TimelinePostInfoEx[]; - onManage?: (item: TManageItems | "property") => void; - onMember: () => void; - onBookmark?: () => void; - onHighlight?: () => void; - onPost?: TimelinePostSendCallback; + operations: { + onManage?: (item: TManageItems | "property") => void; + onMember: () => void; + onBookmark?: () => void; + onHighlight?: () => void; + onPost?: TimelinePostSendCallback; + }; } | I18nText; syncStatus: TimelineSyncStatus; @@ -155,10 +159,7 @@ export default function TimelinePageTemplateUI( (
)} - {data != null && data.onPost != null ? ( + {data != null && data.operations.onPost != null ? ( <>
( /> diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx index f4dbb67d..920f504d 100644 --- a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx +++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx @@ -13,8 +13,8 @@ export type OrdinaryTimelineManageItem = "delete"; export type TimelineInfoCardProps = TimelineCardComponentProps; const TimelineInfoCard: React.FC = (props) => { - const { onMember, onManage, ...otherProps } = props; - const { timeline } = props; + const { timeline, operations } = props; + const { onManage, onMember } = operations; const avatar = useAvatar(timeline?.owner?.username); @@ -66,7 +66,7 @@ const TimelineInfoCard: React.FC = (props) => { }; } })()} - {...otherProps} + {...props} /> ); }; diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx index f31a939f..01d2c096 100644 --- a/FrontEnd/src/app/views/user/UserInfoCard.tsx +++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx @@ -13,8 +13,8 @@ export type PersonalTimelineManageItem = "avatar" | "nickname"; export type UserInfoCardProps = TimelineCardComponentProps; const UserInfoCard: React.FC = (props) => { - const { onMember, onManage, ...otherProps } = props; - const { timeline } = props; + const { timeline, operations } = props; + const { onManage, onMember } = operations; const avatar = useAvatar(timeline?.owner?.username); @@ -66,7 +66,7 @@ const UserInfoCard: React.FC = (props) => { }; } })()} - {...otherProps} + {...props} /> ); }; -- cgit v1.2.3 From 1488919a75a67ad3992e9c66031c9079c50053f2 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 11 Jan 2021 00:34:59 +0800 Subject: ... --- FrontEnd/src/app/services/DataHub2.ts | 171 ++++++++++++++++++++++++++++++++++ FrontEnd/src/app/services/user.ts | 69 +++++--------- 2 files changed, 193 insertions(+), 47 deletions(-) create mode 100644 FrontEnd/src/app/services/DataHub2.ts (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/services/DataHub2.ts b/FrontEnd/src/app/services/DataHub2.ts new file mode 100644 index 00000000..88849da3 --- /dev/null +++ b/FrontEnd/src/app/services/DataHub2.ts @@ -0,0 +1,171 @@ +import { Observable } from "rxjs"; + +export type DataStatus = "syncing" | "synced" | "offline"; + +export type Subscriber = (data: TData) => void; + +export interface DataAndStatus { + data: TData | null; + status: DataStatus; +} + +export class DataLine2 { + constructor( + private config: { + saveData: (data: TData) => Promise; + getSavedData: () => Promise; + // return null for offline + fetchData: (savedData: TData | null) => Promise; + } + ) {} + + private _current: DataAndStatus | null = null; + private _observers: Subscriber>[] = []; + + get currentData(): DataAndStatus | null { + return this._current; + } + + get isDestroyable(): boolean { + const { _observers, currentData } = this; + return ( + _observers.length === 0 && + (currentData == null || currentData.status !== "syncing") + ); + } + + private next(data: DataAndStatus): void { + this._current = data; + this._observers.forEach((o) => o(data)); + } + + subscribe(subsriber: Subscriber>): 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) { + subsriber(currentData); + } + } + + unsubscribe(subsriber: Subscriber>): void { + const index = this._observers.indexOf(subsriber); + if (index > -1) this._observers.splice(index, 1); + } + + getObservalble(): Observable> { + return new Observable>((observer) => { + const f = (data: DataAndStatus): void => { + observer.next(data); + }; + this.subscribe(f); + + return () => { + this.unsubscribe(f); + }; + }); + } + + 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" }); + }); + } + }); + }); + } + + save(data: TData): 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(() => { + this.next({ data: data, status: "synced" }); + }); + } + + getSavedData(): Promise { + return this.config.getSavedData(); + } +} + +export class DataHub2 { + private readonly subscriptionLineMap = new Map>(); + + private keyToString: (key: TKey) => string; + + private cleanTimerId = 0; + + // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called. + constructor( + private config: { + saveData: (key: TKey, data: TData) => Promise; + getSavedData: (key: TKey) => Promise; + fetchData: (key: TKey, savedData: TData | null) => Promise; + keyToString?: (key: TKey) => string; + } + ) { + this.keyToString = + config.keyToString ?? + ((value): string => { + if (typeof value === "string") return value; + else + throw new Error( + "Default keyToString function only pass string value." + ); + }); + } + + private cleanLines(): void { + const toDelete: string[] = []; + for (const [key, line] of this.subscriptionLineMap.entries()) { + if (line.isDestroyable) { + toDelete.push(key); + } + } + + if (toDelete.length === 0) return; + + for (const key of toDelete) { + this.subscriptionLineMap.delete(key); + } + + if (this.subscriptionLineMap.size === 0) { + window.clearInterval(this.cleanTimerId); + this.cleanTimerId = 0; + } + } + + private createLine(key: TKey): DataLine2 { + const keyString = this.keyToString(key); + const newLine: DataLine2 = new DataLine2({ + saveData: (data) => this.config.saveData(key, data), + getSavedData: () => this.config.getSavedData(key), + fetchData: (savedData) => this.config.fetchData(key, savedData), + }); + this.subscriptionLineMap.set(keyString, newLine); + if (this.subscriptionLineMap.size === 1) { + this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000); + } + return newLine; + } + + getLine(key: TKey): DataLine2 { + const keyString = this.keyToString(key); + return this.subscriptionLineMap.get(keyString) ?? this.createLine(key); + } +} diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts index f08802a5..3407ad02 100644 --- a/FrontEnd/src/app/services/user.ts +++ b/FrontEnd/src/app/services/user.ts @@ -25,6 +25,7 @@ import { import { dataStorage, throwIfNotNetworkError } from "./common"; import { DataHub } from "./DataHub"; import { pushAlert } from "./alert"; +import { DataHub2 } from "./DataHub2"; export type User = HttpUser; @@ -251,69 +252,43 @@ export class UserNotExistError extends Error {} export class UserInfoService { saveUser(user: HttpUser): void { - const key = user.username; - void this._userHub.optionalInitLineWithSyncAction(key, async (line) => { - await this.doSaveUser(user); - line.next({ user, type: "synced" }); - }); + this.userHub.getLine(user.username).save(user); } saveUsers(users: HttpUser[]): void { return users.forEach((user) => this.saveUser(user)); } - private _getCachedUser(username: string): Promise { - return dataStorage.getItem(`user.${username}`); - } - - private doSaveUser(user: HttpUser): Promise { - return dataStorage.setItem(`user.${user.username}`, user).then(); + private generateUserDataStorageKey(username: string): string { + return `user.${username}`; } - getCachedUser(username: string): Promise { - return this._getCachedUser(username); - } - - syncUser(username: string): Promise { - return this._userHub.getLineOrCreate(username).sync(); - } - - private _userHub = new DataHub< - string, - | { user: User; type: "cache" | "synced" | "offline" } - | { user?: undefined; type: "notexist" | "offline" } - >({ - sync: async (key, line) => { - if (line.value == undefined) { - const cache = await this._getCachedUser(key); - if (cache != null) { - line.next({ user: cache, type: "cache" }); - } - } - + readonly userHub = new DataHub2({ + saveData: (username, data) => { + if (typeof data === "string") return Promise.resolve(); + return dataStorage + .setItem(this.generateUserDataStorageKey(username), data) + .then(); + }, + getSavedData: (username) => { + return dataStorage.getItem( + this.generateUserDataStorageKey(username) + ); + }, + fetchData: async (username) => { try { - const res = await getHttpUserClient().get(key); - await this.doSaveUser(res); - line.next({ user: res, type: "synced" }); + return await getHttpUserClient().get(username); } catch (e) { if (e instanceof HttpUserNotExistError) { - line.next({ type: "notexist" }); - } else { - const cache = await this._getCachedUser(key); - line.next({ user: cache ?? undefined, type: "offline" }); - throwIfNotNetworkError(e); + return "notexist"; + } else if (e instanceof HttpNetworkError) { + return null; } + throw e; } }, }); - getUser$(username: string): Observable { - return this._userHub.getObservable(username).pipe( - map((state) => state?.user), - filter((user): user is User => user != null) - ); - } - private _getCachedAvatar(username: string): Promise { return dataStorage.getItem(`user.${username}.avatar`); } -- cgit v1.2.3 From 873bb613bc2deb86a4266bac160d14be265f9609 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 11 Jan 2021 21:34:57 +0800 Subject: ... --- FrontEnd/src/app/http/common.ts | 4 + FrontEnd/src/app/http/user.ts | 8 +- FrontEnd/src/app/services/DataHub.ts | 225 -------- FrontEnd/src/app/services/DataHub2.ts | 10 + FrontEnd/src/app/services/common.ts | 19 +- FrontEnd/src/app/services/timeline.ts | 573 +++++++-------------- FrontEnd/src/app/services/user.ts | 140 +++-- .../views/timeline-common/TimelinePageTemplate.tsx | 281 +++++----- .../timeline-common/TimelinePageTemplateUI.tsx | 26 +- 9 files changed, 423 insertions(+), 863 deletions(-) delete mode 100644 FrontEnd/src/app/services/DataHub.ts (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/http/common.ts b/FrontEnd/src/app/http/common.ts index 95d29fb6..0f46280c 100644 --- a/FrontEnd/src/app/http/common.ts +++ b/FrontEnd/src/app/http/common.ts @@ -179,3 +179,7 @@ export function convertToBlobWithEtag(res: AxiosResponse): BlobWithEtag { etag: (res.headers as Record<"etag", string>)["etag"], }; } + +export function extractEtag(res: AxiosResponse): string { + return (res.headers as Record<"etag", string>)["etag"]; +} diff --git a/FrontEnd/src/app/http/user.ts b/FrontEnd/src/app/http/user.ts index 8345880e..19accc42 100644 --- a/FrontEnd/src/app/http/user.ts +++ b/FrontEnd/src/app/http/user.ts @@ -11,6 +11,7 @@ import { BlobWithEtag, convertToBlobWithEtag, convertToNotModified, + extractEtag, } from "./common"; export const kUserManagement = "UserManagement"; @@ -70,7 +71,8 @@ export interface IHttpUserClient { username: string, etag: string ): Promise; - putAvatar(username: string, data: Blob): Promise; + // return etag + putAvatar(username: string, data: Blob): Promise; changePassword(req: HttpChangePasswordRequest): Promise; putUserPermission( username: string, @@ -137,7 +139,7 @@ export class HttpUserClient implements IHttpUserClient { .catch(convertToNetworkError); } - putAvatar(username: string, data: Blob): Promise { + putAvatar(username: string, data: Blob): Promise { return axios .put(`${apiBaseUrl}/users/${username}/avatar`, data, { headers: { @@ -145,7 +147,7 @@ export class HttpUserClient implements IHttpUserClient { }, }) .catch(convertToNetworkError) - .then(); + .then(extractEtag); } changePassword(req: HttpChangePasswordRequest): Promise { diff --git a/FrontEnd/src/app/services/DataHub.ts b/FrontEnd/src/app/services/DataHub.ts deleted file mode 100644 index 4d618db6..00000000 --- a/FrontEnd/src/app/services/DataHub.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { pull } from "lodash"; -import { Observable, BehaviorSubject, combineLatest } from "rxjs"; -import { map } from "rxjs/operators"; - -export type Subscriber = (data: TData) => void; - -export type WithSyncStatus = T & { syncing: boolean }; - -export class DataLine { - private _current: TData | undefined = undefined; - - private _syncPromise: Promise | null = null; - private _syncingSubject = new BehaviorSubject(false); - - private _observers: Subscriber[] = []; - - constructor( - private config: { - sync: () => Promise; - destroyable?: (value: TData | undefined) => boolean; - disableInitSync?: boolean; - } - ) { - if (config.disableInitSync !== true) { - setTimeout(() => void this.sync()); - } - } - - private subscribe(subscriber: Subscriber): void { - this._observers.push(subscriber); - if (this._current !== undefined) { - subscriber(this._current); - } - } - - private unsubscribe(subscriber: Subscriber): void { - if (!this._observers.includes(subscriber)) return; - pull(this._observers, subscriber); - } - - getObservable(): Observable { - return new Observable((observer) => { - const f = (data: TData): void => { - observer.next(data); - }; - this.subscribe(f); - - return () => { - this.unsubscribe(f); - }; - }); - } - - getSyncStatusObservable(): Observable { - return this._syncingSubject.asObservable(); - } - - getDataWithSyncStatusObservable(): Observable> { - return combineLatest([ - this.getObservable(), - this.getSyncStatusObservable(), - ]).pipe( - map(([data, syncing]) => ({ - ...data, - syncing, - })) - ); - } - - get value(): TData | undefined { - return this._current; - } - - next(value: TData): void { - this._current = value; - this._observers.forEach((observer) => observer(value)); - } - - get isSyncing(): boolean { - return this._syncPromise != null; - } - - sync(): Promise { - if (this._syncPromise == null) { - this._syncingSubject.next(true); - this._syncPromise = this.config.sync().then(() => { - this._syncingSubject.next(false); - this._syncPromise = null; - }); - } - - return this._syncPromise; - } - - syncWithAction( - syncAction: (line: DataLine) => Promise - ): Promise { - if (this._syncPromise == null) { - this._syncingSubject.next(true); - this._syncPromise = syncAction(this).then(() => { - this._syncingSubject.next(false); - this._syncPromise = null; - }); - } - - return this._syncPromise; - } - - get destroyable(): boolean { - const customDestroyable = this.config?.destroyable; - - return ( - this._observers.length === 0 && - !this.isSyncing && - (customDestroyable != null ? customDestroyable(this._current) : true) - ); - } -} - -export class DataHub { - private sync: (key: TKey, line: DataLine) => Promise; - private keyToString: (key: TKey) => string; - private destroyable?: (key: TKey, value: TData | undefined) => boolean; - - private readonly subscriptionLineMap = new Map>(); - - private cleanTimerId = 0; - - // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called. - constructor(config: { - sync: (key: TKey, line: DataLine) => Promise; - keyToString?: (key: TKey) => string; - destroyable?: (key: TKey, value: TData | undefined) => boolean; - }) { - this.sync = config.sync; - this.keyToString = - config.keyToString ?? - ((value): string => { - if (typeof value === "string") return value; - else - throw new Error( - "Default keyToString function only pass string value." - ); - }); - - this.destroyable = config.destroyable; - } - - private cleanLines(): void { - const toDelete: string[] = []; - for (const [key, line] of this.subscriptionLineMap.entries()) { - if (line.destroyable) { - toDelete.push(key); - } - } - - if (toDelete.length === 0) return; - - for (const key of toDelete) { - this.subscriptionLineMap.delete(key); - } - - if (this.subscriptionLineMap.size === 0) { - window.clearInterval(this.cleanTimerId); - this.cleanTimerId = 0; - } - } - - private createLine(key: TKey, disableInitSync = false): DataLine { - const keyString = this.keyToString(key); - const { destroyable } = this; - const newLine: DataLine = new DataLine({ - sync: () => this.sync(key, newLine), - destroyable: - destroyable != null ? (value) => destroyable(key, value) : undefined, - disableInitSync: disableInitSync, - }); - this.subscriptionLineMap.set(keyString, newLine); - if (this.subscriptionLineMap.size === 1) { - this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000); - } - return newLine; - } - - getObservable(key: TKey): Observable { - return this.getLineOrCreate(key).getObservable(); - } - - getSyncStatusObservable(key: TKey): Observable { - return this.getLineOrCreate(key).getSyncStatusObservable(); - } - - getDataWithSyncStatusObservable( - key: TKey - ): Observable> { - return this.getLineOrCreate(key).getDataWithSyncStatusObservable(); - } - - getLine(key: TKey): DataLine | null { - const keyString = this.keyToString(key); - return this.subscriptionLineMap.get(keyString) ?? null; - } - - getLineOrCreate(key: TKey): DataLine { - const keyString = this.keyToString(key); - return this.subscriptionLineMap.get(keyString) ?? this.createLine(key); - } - - getLineOrCreateWithoutInitSync(key: TKey): DataLine { - const keyString = this.keyToString(key); - return ( - this.subscriptionLineMap.get(keyString) ?? this.createLine(key, true) - ); - } - - optionalInitLineWithSyncAction( - key: TKey, - syncAction: (line: DataLine) => Promise - ): Promise { - const optionalLine = this.getLine(key); - if (optionalLine != null) return Promise.resolve(); - const line = this.createLine(key, true); - return line.syncWithAction(syncAction); - } -} diff --git a/FrontEnd/src/app/services/DataHub2.ts b/FrontEnd/src/app/services/DataHub2.ts index 88849da3..50ae919b 100644 --- a/FrontEnd/src/app/services/DataHub2.ts +++ b/FrontEnd/src/app/services/DataHub2.ts @@ -2,6 +2,16 @@ import { Observable } from "rxjs"; export type DataStatus = "syncing" | "synced" | "offline"; +export function mergeDataStatus(statusList: DataStatus[]): DataStatus { + if (statusList.includes("offline")) { + return "offline"; + } else if (statusList.includes("syncing")) { + return "syncing"; + } else { + return "synced"; + } +} + export type Subscriber = (data: TData) => void; export interface DataAndStatus { diff --git a/FrontEnd/src/app/services/common.ts b/FrontEnd/src/app/services/common.ts index 3bb6b9d7..9208737b 100644 --- a/FrontEnd/src/app/services/common.ts +++ b/FrontEnd/src/app/services/common.ts @@ -1,6 +1,6 @@ import localforage from "localforage"; -import { HttpNetworkError } from "@/http/common"; +const dataVersion = 1; export const dataStorage = localforage.createInstance({ name: "data", @@ -8,16 +8,17 @@ export const dataStorage = localforage.createInstance({ driver: localforage.INDEXEDDB, }); +void (async () => { + const currentVersion = await dataStorage.getItem("version"); + if (currentVersion !== dataVersion) { + console.log("Data storage version has changed. Clear all data."); + await dataStorage.clear(); + await dataStorage.setItem("version", dataVersion); + } +})(); + export class ForbiddenError extends Error { constructor(message?: string) { super(message); } } - -export function throwIfNotNetworkError(e: unknown): void { - if (!(e instanceof HttpNetworkError)) { - throw e; - } -} - -export type BlobOrStatus = Blob | "loading" | "error"; diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts index 3b9a9072..ed24c005 100644 --- a/FrontEnd/src/app/services/timeline.ts +++ b/FrontEnd/src/app/services/timeline.ts @@ -1,8 +1,6 @@ import React from "react"; import XRegExp from "xregexp"; -import { Observable, from, combineLatest, of } from "rxjs"; -import { map, switchMap, startWith, filter } from "rxjs/operators"; -import { uniqBy } from "lodash"; +import { Observable, from } from "rxjs"; import { convertError } from "@/utilities/rxjs"; import { @@ -19,16 +17,15 @@ import { HttpTimelineNotExistError, HttpTimelineNameConflictError, } from "@/http/timeline"; -import { BlobWithEtag, NotModified, HttpForbiddenError } from "@/http/common"; -import { HttpUser } from "@/http/user"; +import { HttpForbiddenError, HttpNetworkError } from "@/http/common"; export { kTimelineVisibilities } from "@/http/timeline"; export type { TimelineVisibility } from "@/http/timeline"; -import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common"; -import { DataHub, WithSyncStatus } from "./DataHub"; -import { userInfoService, User, AuthUser } from "./user"; +import { dataStorage } from "./common"; +import { userInfoService, AuthUser } from "./user"; +import { DataAndStatus, DataHub2 } from "./DataHub2"; export type TimelineInfo = HttpTimelineInfo; export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; @@ -41,19 +38,21 @@ export type TimelinePostTextContent = HttpTimelinePostTextContent; export interface TimelinePostImageContent { type: "image"; - data: BlobOrStatus; + data: Blob; + etag: string; } export type TimelinePostContent = | TimelinePostTextContent | TimelinePostImageContent; -export interface TimelinePostInfo { - id: number; +export type TimelinePostInfo = Omit & { content: TimelinePostContent; - time: Date; +}; + +export interface TimelinePostsInfo { lastUpdated: Date; - author: HttpUser; + posts: TimelinePostInfo[]; } export const timelineVisibilityTooltipTranslationMap: Record< @@ -65,55 +64,23 @@ export const timelineVisibilityTooltipTranslationMap: Record< Private: "timeline.visibilityTooltip.private", }; -export class TimelineNotExistError extends Error {} export class TimelineNameConflictError extends Error {} -export type TimelineWithSyncStatus = WithSyncStatus< - | { - type: "cache"; - timeline: TimelineInfo; - } - | { - type: "offline" | "synced"; - timeline: TimelineInfo | null; - } ->; - -export type TimelinePostsWithSyncState = WithSyncStatus<{ - type: - | "cache" - | "offline" // Sync failed and use cache. - | "synced" // Sync succeeded. - | "forbid" // The list is forbidden to see. - | "notexist"; // The timeline does not exist. - posts: TimelinePostInfo[]; -}>; - type TimelineData = Omit & { owner: string; members: string[]; }; -type TimelinePostData = Omit & { +type TimelinePostData = Omit & { author: string; }; -export class TimelineService { - private getCachedTimeline( - timelineName: string - ): Promise { - return dataStorage.getItem(`timeline.${timelineName}`); - } - - private saveTimeline( - timelineName: string, - data: TimelineData - ): Promise { - return dataStorage - .setItem(`timeline.${timelineName}`, data) - .then(); - } +interface TimelinePostsData { + lastUpdated: Date; + posts: TimelinePostData[]; +} +export class TimelineService { private async clearTimelineData(timelineName: string): Promise { const keys = (await dataStorage.keys()).filter((k) => k.startsWith(`timeline.${timelineName}`) @@ -121,6 +88,10 @@ export class TimelineService { await Promise.all(keys.map((k) => dataStorage.removeItem(k))); } + private generateTimelineDataStorageKey(timelineName: string): string { + return `timeline.${timelineName}`; + } + private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData { return { ...timeline, @@ -129,95 +100,65 @@ export class TimelineService { }; } - private _timelineHub = new DataHub< - string, - | { - type: "cache"; - timeline: TimelineData; - } - | { - type: "offline" | "synced"; - timeline: TimelineData | null; - } - >({ - sync: async (key, line) => { - const cache = await this.getCachedTimeline(key); + readonly timelineHub = new DataHub2({ + saveData: async (timelineName, data) => { + if (data === "notexist") return; - if (line.value == undefined) { - if (cache != null) { - line.next({ type: "cache", timeline: cache }); - } - } + userInfoService.saveUser(data.owner); + userInfoService.saveUsers(data.members); - try { - const httpTimeline = await getHttpTimelineClient().getTimeline(key); + await dataStorage.setItem( + this.generateTimelineDataStorageKey(timelineName), + this.convertHttpTimelineToData(data) + ); + }, + getSavedData: async (timelineName) => { + const savedData = await dataStorage.getItem( + this.generateTimelineDataStorageKey(timelineName) + ); - userInfoService.saveUsers([ - httpTimeline.owner, - ...httpTimeline.members, - ]); + if (savedData == null) return null; - const timeline = this.convertHttpTimelineToData(httpTimeline); + const owner = await userInfoService.getCachedUser(savedData.owner); + if (owner == null) return null; + const members = await userInfoService.getCachedUsers(savedData.members); + if (members == null) return null; - if (cache != null && timeline.uniqueId !== cache.uniqueId) { + return { ...savedData, owner, members }; + }, + fetchData: async (timelineName, savedData) => { + try { + const timeline = await getHttpTimelineClient().getTimeline( + timelineName + ); + + if ( + savedData != null && + savedData !== "notexist" && + savedData.uniqueId !== timeline.uniqueId + ) { console.log( - `Timeline with name ${key} has changed to a new one. Clear old data.` + `Timeline with name ${timelineName} has changed to a new one. Clear old data.` ); - await this.clearTimelineData(key); // If timeline has changed, clear all old data. - } - await this.saveTimeline(key, timeline); + void this.clearTimelineData(timelineName); // If timeline has changed, clear all old data. + } - line.next({ type: "synced", timeline }); + return timeline; } catch (e) { if (e instanceof HttpTimelineNotExistError) { - line.next({ type: "synced", timeline: null }); + return "notexist"; + } else if (e instanceof HttpNetworkError) { + return null; } else { - if (cache == null) { - line.next({ type: "offline", timeline: null }); - } else { - line.next({ type: "offline", timeline: cache }); - } - throwIfNotNetworkError(e); + throw e; } } }, }); - syncTimeline(timelineName: string): Promise { - return this._timelineHub.getLineOrCreate(timelineName).sync(); - } - - getTimeline$(timelineName: string): Observable { - return this._timelineHub.getDataWithSyncStatusObservable(timelineName).pipe( - switchMap((state) => { - const { timeline } = state; - if (timeline != null) { - return combineLatest( - [timeline.owner, ...timeline.members].map((u) => - state.type === "cache" - ? from(userInfoService.getCachedUser(u)).pipe( - filter((u): u is User => u != null) - ) - : userInfoService.getUser$(u) - ) - ).pipe( - map((users) => { - return { - ...state, - timeline: { - ...timeline, - owner: users[0], - members: users.slice(1), - }, - }; - }) - ); - } else { - return of(state as TimelineWithSyncStatus); - } - }) - ); + syncTimeline(timelineName: string): void { + this.timelineHub.getLine(timelineName).sync(); } createTimeline(timelineName: string): Observable { @@ -268,291 +209,145 @@ export class TimelineService { ); } - private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData { - return { - ...post, - author: post.author.username, - }; + private generatePostsDataStorageKey(timelineName: string): string { + return `timeline.${timelineName}.posts`; } - private convertHttpPostToDataList( - posts: HttpTimelinePostInfo[] - ): TimelinePostData[] { - return posts.map((post) => this.convertHttpPostToData(post)); - } + readonly postsHub = new DataHub2< + string, + TimelinePostsInfo | "notexist" | "forbid" + >({ + saveData: async (timelineName, data) => { + if (data === "notexist" || data === "forbid") return; - private getCachedPosts( - timelineName: string - ): Promise { - return dataStorage.getItem( - `timeline.${timelineName}.posts` - ); - } + const savedData: TimelinePostsData = { + ...data, + posts: data.posts.map((p) => ({ ...p, author: p.author.username })), + }; - private savePosts( - timelineName: string, - data: TimelinePostData[] - ): Promise { - return dataStorage - .setItem(`timeline.${timelineName}.posts`, data) - .then(); - } + data.posts.forEach((p) => { + userInfoService.saveUser(p.author); + }); - private syncPosts(timelineName: string): Promise { - return this._postsHub.getLineOrCreate(timelineName).sync(); - } + await dataStorage.setItem( + this.generatePostsDataStorageKey(timelineName), + savedData + ); + }, + getSavedData: async (timelineName) => { + const savedData = await dataStorage.getItem( + this.generatePostsDataStorageKey(timelineName) + ); + if (savedData == null) return null; - private _postsHub = new DataHub< - string, - { - type: "cache" | "offline" | "synced" | "forbid" | "notexist"; - posts: TimelinePostData[]; - } - >({ - sync: async (key, line) => { - // Wait for timeline synced. In case the timeline has changed to another and old data has been cleaned. - await this.syncTimeline(key); - - if (line.value == null) { - const cache = await this.getCachedPosts(key); - if (cache != null) { - line.next({ type: "cache", posts: cache }); + const authors = await userInfoService.getCachedUsers( + savedData.posts.map((p) => p.author) + ); + + if (authors == null) return null; + + return { + ...savedData, + posts: savedData.posts.map((p, index) => ({ + ...p, + author: authors[index], + })), + }; + }, + fetchData: async (timelineName, savedData) => { + const convert = async ( + post: HttpTimelinePostInfo + ): Promise => { + const { content } = post; + if (content.type === "text") { + return { ...post, content }; + } else { + const data = await getHttpTimelineClient().getPostData( + timelineName, + post.id + ); + return { + ...post, + content: { + type: "image", + data: data.data, + etag: data.etag, + }, + }; } - } + }; - const now = new Date(); + const convertList = ( + posts: HttpTimelinePostInfo[] + ): Promise => + Promise.all(posts.map((p) => convert(p))); - const lastUpdatedTime = await dataStorage.getItem( - `timeline.${key}.lastUpdated` - ); + const now = new Date(); try { - if (lastUpdatedTime == null) { - const httpPosts = await getHttpTimelineClient().listPost(key); - - userInfoService.saveUsers( - uniqBy( - httpPosts.map((post) => post.author), - "username" - ) + if ( + savedData == null || + savedData === "forbid" || + savedData === "notexist" + ) { + const httpPosts = await getHttpTimelineClient().listPost( + timelineName ); - const posts = this.convertHttpPostToDataList(httpPosts); - await this.savePosts(key, posts); - await dataStorage.setItem(`timeline.${key}.lastUpdated`, now); - - line.next({ type: "synced", posts }); + return { + lastUpdated: now, + posts: await convertList(httpPosts), + }; } else { - const httpPosts = await getHttpTimelineClient().listPost(key, { - modifiedSince: lastUpdatedTime, - includeDeleted: true, - }); + const httpPosts = await getHttpTimelineClient().listPost( + timelineName, + { + modifiedSince: savedData.lastUpdated, + includeDeleted: true, + } + ); const deletedIds = httpPosts .filter((p) => p.deleted) .map((p) => p.id); - const changed = httpPosts.filter( - (p): p is HttpTimelinePostInfo => !p.deleted - ); - userInfoService.saveUsers( - uniqBy( - httpPosts - .map((post) => post.author) - .filter((u): u is HttpUser => u != null), - "username" - ) + const changed = await convertList( + httpPosts.filter((p): p is HttpTimelinePostInfo => !p.deleted) ); - const cache = (await this.getCachedPosts(key)) ?? []; - - const posts = cache.filter((p) => !deletedIds.includes(p.id)); + const posts = savedData.posts.filter( + (p) => !deletedIds.includes(p.id) + ); for (const changedPost of changed) { const savedChangedPostIndex = posts.findIndex( (p) => p.id === changedPost.id ); if (savedChangedPostIndex === -1) { - posts.push(this.convertHttpPostToData(changedPost)); + posts.push(await convert(changedPost)); } else { - posts[savedChangedPostIndex] = this.convertHttpPostToData( - changedPost - ); + posts[savedChangedPostIndex] = await convert(changedPost); } } - await this.savePosts(key, posts); - await dataStorage.setItem(`timeline.${key}.lastUpdated`, now); - line.next({ type: "synced", posts }); + return { lastUpdated: now, posts }; } } catch (e) { if (e instanceof HttpTimelineNotExistError) { - line.next({ type: "notexist", posts: [] }); + return "notexist"; } else if (e instanceof HttpForbiddenError) { - line.next({ type: "forbid", posts: [] }); + return "forbid"; + } else if (e instanceof HttpNetworkError) { + return null; } else { - const cache = await this.getCachedPosts(key); - if (cache == null) { - line.next({ type: "offline", posts: [] }); - } else { - line.next({ type: "offline", posts: cache }); - } - throwIfNotNetworkError(e); + throw e; } } }, }); - getPosts$(timelineName: string): Observable { - return this._postsHub.getDataWithSyncStatusObservable(timelineName).pipe( - switchMap((state) => { - if (state.posts.length === 0) { - return of({ - ...state, - posts: [], - }); - } - - return combineLatest([ - combineLatest( - state.posts.map((post) => - state.type === "cache" - ? from(userInfoService.getCachedUser(post.author)).pipe( - filter((u): u is User => u != null) - ) - : userInfoService.getUser$(post.author) - ) - ), - combineLatest( - state.posts.map((post) => { - if (post.content.type === "image") { - return state.type === "cache" - ? from(this.getCachedPostData(timelineName, post.id)) - : this.getPostData$(timelineName, post.id); - } else { - return of(null); - } - }) - ), - ]).pipe( - map(([authors, datas]) => { - return { - ...state, - posts: state.posts.map((post, i) => { - const { content } = post; - - return { - ...post, - author: authors[i], - content: (() => { - if (content.type === "text") return content; - else - return { - type: "image", - data: datas[i], - } as TimelinePostImageContent; - })(), - }; - }), - }; - }) - ); - }) - ); - } - - private _getCachedPostData(key: { - timelineName: string; - postId: number; - }): Promise { - return dataStorage.getItem( - `timeline.${key.timelineName}.post.${key.postId}.data` - ); - } - - private savePostData( - key: { - timelineName: string; - postId: number; - }, - data: BlobWithEtag - ): Promise { - return dataStorage - .setItem( - `timeline.${key.timelineName}.post.${key.postId}.data`, - data - ) - .then(); - } - - private syncPostData(key: { - timelineName: string; - postId: number; - }): Promise { - return this._postDataHub.getLineOrCreate(key).sync(); - } - - private _postDataHub = new DataHub< - { timelineName: string; postId: number }, - | { data: Blob; type: "cache" | "synced" | "offline" } - | { data?: undefined; type: "notexist" | "offline" } - >({ - keyToString: (key) => `${key.timelineName}.${key.postId}`, - sync: async (key, line) => { - const cache = await this._getCachedPostData(key); - if (line.value == null) { - if (cache != null) { - line.next({ type: "cache", data: cache.data }); - } - } - - if (cache == null) { - try { - const res = await getHttpTimelineClient().getPostData( - key.timelineName, - key.postId - ); - await this.savePostData(key, res); - line.next({ data: res.data, type: "synced" }); - } catch (e) { - line.next({ type: "offline" }); - throwIfNotNetworkError(e); - } - } else { - try { - const res = await getHttpTimelineClient().getPostData( - key.timelineName, - key.postId, - cache.etag - ); - if (res instanceof NotModified) { - line.next({ data: cache.data, type: "synced" }); - } else { - await this.savePostData(key, res); - line.next({ data: res.data, type: "synced" }); - } - } catch (e) { - line.next({ data: cache.data, type: "offline" }); - throwIfNotNetworkError(e); - } - } - }, - }); - - getCachedPostData( - timelineName: string, - postId: number - ): Promise { - return this._getCachedPostData({ timelineName, postId }).then( - (d) => d?.data ?? null - ); - } - - getPostData$(timelineName: string, postId: number): Observable { - return this._postDataHub.getObservable({ timelineName, postId }).pipe( - map((state): BlobOrStatus => state.data ?? "error"), - startWith("loading") - ); + syncPosts(timelineName: string): void { + this.postsHub.getLine(timelineName).sync(); } createPost( @@ -563,7 +358,7 @@ export class TimelineService { getHttpTimelineClient() .postPost(timelineName, request) .then(() => { - void this.syncPosts(timelineName); + this.syncPosts(timelineName); }) ); } @@ -573,7 +368,7 @@ export class TimelineService { getHttpTimelineClient() .deletePost(timelineName, postId) .then(() => { - void this.syncPosts(timelineName); + this.syncPosts(timelineName); }) ); } @@ -654,18 +449,22 @@ export function validateTimelineName(name: string): boolean { return timelineNameReg.test(name); } -export function useTimelineInfo( +export function useTimeline( timelineName: string ): [ - TimelineWithSyncStatus | undefined, - React.Dispatch> + DataAndStatus, + React.Dispatch>> ] { - const [state, setState] = React.useState( - undefined - ); + const [state, setState] = React.useState< + DataAndStatus + >({ + status: "syncing", + data: null, + }); React.useEffect(() => { - const subscription = timelineService - .getTimeline$(timelineName) + const subscription = timelineService.timelineHub + .getLine(timelineName) + .getObservalble() .subscribe((data) => { setState(data); }); @@ -676,20 +475,16 @@ export function useTimelineInfo( return [state, setState]; } -export function usePostList( - timelineName: string | null | undefined -): TimelinePostsWithSyncState | undefined { +export function usePosts( + timelineName: string +): DataAndStatus { const [state, setState] = React.useState< - TimelinePostsWithSyncState | undefined - >(undefined); + DataAndStatus + >({ status: "syncing", data: null }); React.useEffect(() => { - if (timelineName == null) { - setState(undefined); - return; - } - - const subscription = timelineService - .getPosts$(timelineName) + const subscription = timelineService.postsHub + .getLine(timelineName) + .getObservalble() .subscribe((data) => { setState(data); }); diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts index 3407ad02..5c4e3ae0 100644 --- a/FrontEnd/src/app/services/user.ts +++ b/FrontEnd/src/app/services/user.ts @@ -1,9 +1,7 @@ import React, { useState, useEffect } from "react"; import { BehaviorSubject, Observable, from } from "rxjs"; -import { map, filter } from "rxjs/operators"; import { UiLogicError } from "@/common"; -import { convertError } from "@/utilities/rxjs"; import { HttpNetworkError, @@ -22,10 +20,9 @@ import { UserPermission, } from "@/http/user"; -import { dataStorage, throwIfNotNetworkError } from "./common"; -import { DataHub } from "./DataHub"; -import { pushAlert } from "./alert"; import { DataHub2 } from "./DataHub2"; +import { dataStorage } from "./common"; +import { pushAlert } from "./alert"; export type User = HttpUser; @@ -259,6 +256,26 @@ export class UserInfoService { return users.forEach((user) => this.saveUser(user)); } + async getCachedUser(username: string): Promise { + const user = await this.userHub.getLine(username).getSavedData(); + if (user == null || user === "notexist") return null; + return user; + } + + async getCachedUsers(usernames: string[]): Promise { + const users = await Promise.all( + usernames.map((username) => this.userHub.getLine(username).getSavedData()) + ); + + for (const u of users) { + if (u == null || u === "notexist") { + return null; + } + } + + return users as HttpUser[]; + } + private generateUserDataStorageKey(username: string): string { return `user.${username}`; } @@ -289,80 +306,52 @@ export class UserInfoService { }, }); - private _getCachedAvatar(username: string): Promise { - return dataStorage.getItem(`user.${username}.avatar`); - } - - private saveAvatar(username: string, data: BlobWithEtag): Promise { - return dataStorage - .setItem(`user.${username}.avatar`, data) - .then(); - } - - getCachedAvatar(username: string): Promise { - return this._getCachedAvatar(username).then((d) => d?.data ?? null); + private generateAvatarDataStorageKey(username: string): string { + return `user.${username}.avatar`; } - syncAvatar(username: string): Promise { - return this._avatarHub.getLineOrCreate(username).sync(); - } - - private _avatarHub = new DataHub< - string, - | { data: Blob; type: "cache" | "synced" | "offline" } - | { data?: undefined; type: "notexist" | "offline" } - >({ - sync: async (key, line) => { - const cache = await this._getCachedAvatar(key); - if (line.value == null) { - if (cache != null) { - line.next({ data: cache.data, type: "cache" }); - } - } - - if (cache == null) { - try { - const avatar = await getHttpUserClient().getAvatar(key); - await this.saveAvatar(key, avatar); - line.next({ data: avatar.data, type: "synced" }); - } catch (e) { - line.next({ type: "offline" }); - throwIfNotNetworkError(e); - } - } else { - try { - const res = await getHttpUserClient().getAvatar(key, cache.etag); + readonly avatarHub = new DataHub2({ + saveData: async (username, data) => { + if (typeof data === "string") return; + await dataStorage.setItem( + this.generateAvatarDataStorageKey(username), + data + ); + }, + getSavedData: (username) => + dataStorage.getItem( + this.generateAvatarDataStorageKey(username) + ), + fetchData: async (username, savedData) => { + try { + if (savedData == null || savedData === "notexist") { + return await getHttpUserClient().getAvatar(username); + } else { + const res = await getHttpUserClient().getAvatar( + username, + savedData.etag + ); if (res instanceof NotModified) { - line.next({ data: cache.data, type: "synced" }); + return savedData; } else { - const avatar = res; - await this.saveAvatar(key, avatar); - line.next({ data: avatar.data, type: "synced" }); + return res; } - } catch (e) { - line.next({ data: cache.data, type: "offline" }); - throwIfNotNetworkError(e); + } + } catch (e) { + if (e instanceof HttpUserNotExistError) { + return "notexist"; + } else if (e instanceof HttpNetworkError) { + return null; + } else { + throw e; } } }, }); - getAvatar$(username: string): Observable { - return this._avatarHub.getObservable(username).pipe( - map((state) => state.data), - filter((blob): blob is Blob => blob != null) - ); - } - - getUserInfo(username: string): Observable { - return from(getHttpUserClient().get(username)).pipe( - convertError(HttpUserNotExistError, UserNotExistError) - ); - } - async setAvatar(username: string, blob: Blob): Promise { - await getHttpUserClient().putAvatar(username, blob); - this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" }); + const etag = await getHttpUserClient().putAvatar(username, blob); + this.avatarHub.getLine(username).save({ data: blob, etag }); } async setNickname(username: string, nickname: string): Promise { @@ -384,14 +373,21 @@ export function useAvatar(username?: string): Blob | undefined { return; } - const subscription = userInfoService - .getAvatar$(username) - .subscribe((blob) => { - setState(blob); + const subscription = userInfoService.avatarHub + .getLine(username) + .getObservalble() + .subscribe((data) => { + if (data.data != null && data.data !== "notexist") { + setState(data.data.data); + } else { + setState(undefined); + } }); + return () => { subscription.unsubscribe(); }; }, [username]); + return state; } diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index f66d14e0..b4058fbe 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -1,25 +1,20 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { of } from "rxjs"; -import { catchError } from "rxjs/operators"; import { UiLogicError } from "@/common"; import { pushAlert } from "@/services/alert"; -import { useUser, userInfoService, UserNotExistError } from "@/services/user"; -import { - timelineService, - usePostList, - useTimelineInfo, -} from "@/services/timeline"; +import { useUser } from "@/services/user"; +import { timelineService, usePosts, useTimeline } from "@/services/timeline"; import { getHttpBookmarkClient } from "@/http/bookmark"; import { getHttpHighlightClient } from "@/http/highlight"; +import { getHttpUserClient, HttpUserNotExistError } from "@/http/user"; import { TimelineMemberDialog } from "./TimelineMember"; import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI"; import { TimelinePostSendCallback } from "./TimelinePostEdit"; -import { TimelineSyncStatus } from "./SyncStatusBadge"; import { TimelinePostInfoEx } from "./Timeline"; +import { mergeDataStatus } from "@/services/DataHub2"; export interface TimelinePageTemplateProps { name: string; @@ -45,8 +40,8 @@ export default function TimelinePageTemplate( null ); - const [timelineState, setTimelineState] = useTimelineInfo(name); - const postListState = usePostList(name); + const [timelineAndStatus, setTimelineAndStatus] = useTimeline(name); + const postsAndState = usePosts(name); const onPost: TimelinePostSendCallback = React.useCallback( (req) => { @@ -68,147 +63,122 @@ export default function TimelinePageTemplate( [onManageProp] ); - const childProps = ((): [ - data: TimelinePageTemplateUIProps["data"], - syncStatus: TimelineSyncStatus - ] => { - if (timelineState == null) { - return [undefined, "syncing"]; + const data = ((): TimelinePageTemplateUIProps["data"] => { + const { status, data: timeline } = timelineAndStatus; + if (timeline == null) { + if (status === "offline") { + return { type: "custom", value: "Network Error" }; + } else { + return undefined; + } + } else if (timeline === "notexist") { + return props.notFoundI18nKey; } else { - const { type, timeline } = timelineState; - if (timeline == null) { - if (type === "offline") { - return [{ type: "custom", value: "Network Error" }, "offline"]; - } else if (type === "synced") { - return [props.notFoundI18nKey, "synced"]; + const posts = ((): TimelinePostInfoEx[] | "forbid" | undefined => { + const { data: postsInfo } = postsAndState; + if (postsInfo === "forbid") { + return "forbid"; + } else if (postsInfo === "notexist") { + return undefined; + } else if (postsInfo == null) { + return undefined; } else { - return [undefined, "syncing"]; - } - } else { - if (postListState != null && postListState.type === "notexist") { - return [props.notFoundI18nKey, "synced"]; - } - if (postListState != null && postListState.type === "forbid") { - return ["timeline.messageCantSee", "synced"]; + return postsInfo.posts.map((post) => ({ + ...post, + onDelete: service.hasModifyPostPermission(user, timeline, post) + ? () => { + service.deletePost(name, post.id).subscribe({ + error: () => { + pushAlert({ + type: "danger", + message: t("timeline.deletePostFailed"), + }); + }, + }); + } + : undefined, + })); } + })(); - const posts: - | TimelinePostInfoEx[] - | undefined = postListState?.posts?.map((post) => ({ - ...post, - onDelete: service.hasModifyPostPermission(user, timeline, post) + const operations = { + onPost: service.hasPostPermission(user, timeline) ? onPost : undefined, + onManage: service.hasManagePermission(user, timeline) + ? onManage + : undefined, + onMember: () => setDialog("member"), + onBookmark: + user != null ? () => { - service.deletePost(name, post.id).subscribe({ - error: () => { + const { isBookmark } = timeline; + setTimelineAndStatus({ + ...timelineAndStatus, + data: { + ...timeline, + isBookmark: !isBookmark, + }, + }); + const client = getHttpBookmarkClient(); + const promise = isBookmark + ? client.delete(name) + : client.put(name); + promise.then( + () => { + void timelineService.syncTimeline(name); + }, + () => { pushAlert({ + message: { + type: "i18n", + key: isBookmark + ? "timeline.removeBookmarkFail" + : "timeline.addBookmarkFail", + }, type: "danger", - message: t("timeline.deletePostFailed"), }); + setTimelineAndStatus(timelineAndStatus); + } + ); + } + : undefined, + onHighlight: + user != null && user.hasHighlightTimelineAdministrationPermission + ? () => { + const { isHighlight } = timeline; + setTimelineAndStatus({ + ...timelineAndStatus, + data: { + ...timeline, + isHighlight: !isHighlight, }, }); + const client = getHttpHighlightClient(); + const promise = isHighlight + ? client.delete(name) + : client.put(name); + promise.then( + () => { + void timelineService.syncTimeline(name); + }, + () => { + pushAlert({ + message: { + type: "i18n", + key: isHighlight + ? "timeline.removeHighlightFail" + : "timeline.addHighlightFail", + }, + type: "danger", + }); + setTimelineAndStatus(timelineAndStatus); + } + ); } : undefined, - })); - - const operations = { - onPost: service.hasPostPermission(user, timeline) - ? onPost - : undefined, - onManage: service.hasManagePermission(user, timeline) - ? onManage - : undefined, - onMember: () => setDialog("member"), - onBookmark: - user != null - ? () => { - const { isBookmark } = timeline; - setTimelineState({ - ...timelineState, - timeline: { - ...timeline, - isBookmark: !isBookmark, - }, - }); - const client = getHttpBookmarkClient(); - const promise = isBookmark - ? client.delete(name) - : client.put(name); - promise.then( - () => { - void timelineService.syncTimeline(name); - }, - () => { - pushAlert({ - message: { - type: "i18n", - key: isBookmark - ? "timeline.removeBookmarkFail" - : "timeline.addBookmarkFail", - }, - type: "danger", - }); - setTimelineState(timelineState); - } - ); - } - : undefined, - onHighlight: - user != null && user.hasHighlightTimelineAdministrationPermission - ? () => { - const { isHighlight } = timeline; - setTimelineState({ - ...timelineState, - timeline: { - ...timeline, - isHighlight: !isHighlight, - }, - }); - const client = getHttpHighlightClient(); - const promise = isHighlight - ? client.delete(name) - : client.put(name); - promise.then( - () => { - void timelineService.syncTimeline(name); - }, - () => { - pushAlert({ - message: { - type: "i18n", - key: isHighlight - ? "timeline.removeHighlightFail" - : "timeline.addHighlightFail", - }, - type: "danger", - }); - setTimelineState(timelineState); - } - ); - } - : undefined, - }; + }; - if (type === "cache") { - return [{ timeline, posts, operations }, "syncing"]; - } else if (type === "offline") { - return [{ timeline, posts, operations }, "offline"]; - } else { - if (postListState == null) { - return [{ timeline, posts, operations }, "syncing"]; - } else { - const { type: postListType } = postListState; - if (postListType === "synced") { - return [{ timeline, posts, operations }, "synced"]; - } else if (postListType === "cache") { - return [{ timeline, posts, operations }, "syncing"]; - } else if (postListType === "offline") { - return [{ timeline, posts, operations }, "offline"]; - } - } - } - } + return { timeline, posts, operations }; } - throw new UiLogicError("Failed to calculate TimelinePageUITemplate props."); })(); const closeDialog = React.useCallback((): void => { @@ -217,10 +187,10 @@ export default function TimelinePageTemplate( let dialogElement: React.ReactElement | undefined; - const timeline = timelineState?.timeline; + const timeline = timelineAndStatus?.data; if (dialog === "property") { - if (timeline == null) { + if (timeline == null || timeline === "notexist") { throw new UiLogicError( "Timeline is null but attempt to open change property dialog." ); @@ -241,7 +211,7 @@ export default function TimelinePageTemplate( /> ); } else if (dialog === "member") { - if (timeline == null) { + if (timeline == null || timeline === "notexist") { throw new UiLogicError( "Timeline is null but attempt to open change property dialog." ); @@ -256,18 +226,15 @@ export default function TimelinePageTemplate( service.hasManagePermission(user, timeline) ? { onCheckUser: (u) => { - return userInfoService - .getUserInfo(u) - .pipe( - catchError((e) => { - if (e instanceof UserNotExistError) { - return of(null); - } else { - throw e; - } - }) - ) - .toPromise(); + return getHttpUserClient() + .get(u) + .catch((e) => { + if (e instanceof HttpUserNotExistError) { + return null; + } else { + throw e; + } + }); }, onAddUser: (u) => { return service.addMember(name, u.username).toPromise().then(); @@ -286,7 +253,13 @@ export default function TimelinePageTemplate( return ( <> - + {dialogElement} ); diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx index b2824c84..41246175 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx @@ -29,7 +29,7 @@ export interface TimelinePageTemplateUIProps { data?: | { timeline: TimelineInfo; - posts?: TimelinePostInfoEx[]; + posts?: TimelinePostInfoEx[] | "forbid"; operations: { onManage?: (item: TManageItems | "property") => void; onMember: () => void; @@ -166,16 +166,20 @@ export default function TimelinePageTemplateUI( /> ) : null} {posts != null ? ( -
- -
+ posts === "forbid" ? ( +
{t("timeline.messageCantSee")}
+ ) : ( +
+ +
+ ) ) : (
-- cgit v1.2.3 From 555fe9fb1ae5227a535e6beff05e7237cbbccfce Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 11 Jan 2021 21:43:11 +0800 Subject: ... --- FrontEnd/src/app/services/timeline.ts | 7 ++----- .../app/views/timeline-common/TimelinePageTemplate.tsx | 18 +----------------- 2 files changed, 3 insertions(+), 22 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts index ed24c005..46feb4d9 100644 --- a/FrontEnd/src/app/services/timeline.ts +++ b/FrontEnd/src/app/services/timeline.ts @@ -451,10 +451,7 @@ export function validateTimelineName(name: string): boolean { export function useTimeline( timelineName: string -): [ - DataAndStatus, - React.Dispatch>> -] { +): DataAndStatus { const [state, setState] = React.useState< DataAndStatus >({ @@ -472,7 +469,7 @@ export function useTimeline( subscription.unsubscribe(); }; }, [timelineName]); - return [state, setState]; + return state; } export function usePosts( diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index b4058fbe..d7e4d696 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -40,7 +40,7 @@ export default function TimelinePageTemplate( null ); - const [timelineAndStatus, setTimelineAndStatus] = useTimeline(name); + const timelineAndStatus = useTimeline(name); const postsAndState = usePosts(name); const onPost: TimelinePostSendCallback = React.useCallback( @@ -111,13 +111,6 @@ export default function TimelinePageTemplate( user != null ? () => { const { isBookmark } = timeline; - setTimelineAndStatus({ - ...timelineAndStatus, - data: { - ...timeline, - isBookmark: !isBookmark, - }, - }); const client = getHttpBookmarkClient(); const promise = isBookmark ? client.delete(name) @@ -136,7 +129,6 @@ export default function TimelinePageTemplate( }, type: "danger", }); - setTimelineAndStatus(timelineAndStatus); } ); } @@ -145,13 +137,6 @@ export default function TimelinePageTemplate( user != null && user.hasHighlightTimelineAdministrationPermission ? () => { const { isHighlight } = timeline; - setTimelineAndStatus({ - ...timelineAndStatus, - data: { - ...timeline, - isHighlight: !isHighlight, - }, - }); const client = getHttpHighlightClient(); const promise = isHighlight ? client.delete(name) @@ -170,7 +155,6 @@ export default function TimelinePageTemplate( }, type: "danger", }); - setTimelineAndStatus(timelineAndStatus); } ); } -- cgit v1.2.3 From ce08fc3865a117ee2b7f56d25fc96e80efc13587 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 11 Jan 2021 21:44:44 +0800 Subject: ... --- FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index d7e4d696..f5dec67c 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -78,9 +78,7 @@ export default function TimelinePageTemplate( const { data: postsInfo } = postsAndState; if (postsInfo === "forbid") { return "forbid"; - } else if (postsInfo === "notexist") { - return undefined; - } else if (postsInfo == null) { + } else if (postsInfo == null || postsInfo === "notexist") { return undefined; } else { return postsInfo.posts.map((post) => ({ -- cgit v1.2.3 From 26f02d90c2571251b32c3b03b970dd290e3892e6 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 11 Jan 2021 21:54:33 +0800 Subject: ... --- FrontEnd/src/app/services/timeline.ts | 28 ++++---- .../app/views/timeline-common/TimelineMember.tsx | 78 +++++++++++++--------- .../views/timeline-common/TimelinePageTemplate.tsx | 27 +------- 3 files changed, 61 insertions(+), 72 deletions(-) (limited to 'FrontEnd/src') diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts index 46feb4d9..8bc1d40b 100644 --- a/FrontEnd/src/app/services/timeline.ts +++ b/FrontEnd/src/app/services/timeline.ts @@ -189,24 +189,20 @@ export class TimelineService { return from(getHttpTimelineClient().deleteTimeline(timelineName)); } - addMember(timelineName: string, username: string): Observable { - return from( - getHttpTimelineClient() - .memberPut(timelineName, username) - .then(() => { - void this.syncTimeline(timelineName); - }) - ); + addMember(timelineName: string, username: string): Promise { + return getHttpTimelineClient() + .memberPut(timelineName, username) + .then(() => { + void this.syncTimeline(timelineName); + }); } - removeMember(timelineName: string, username: string): Observable { - return from( - getHttpTimelineClient() - .memberDelete(timelineName, username) - .then(() => { - void this.syncTimeline(timelineName); - }) - ); + removeMember(timelineName: string, username: string): Promise { + return getHttpTimelineClient() + .memberDelete(timelineName, username) + .then(() => { + void this.syncTimeline(timelineName); + }); } private generatePostsDataStorageKey(timelineName: string): string { diff --git a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx index 089d11a0..efa7e971 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx @@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"; import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap"; import { User, useAvatar } from "@/services/user"; +import { TimelineInfo, timelineService } from "@/services/timeline"; +import { getHttpUserClient, HttpUserNotExistError } from "@/http/user"; import SearchInput from "../common/SearchInput"; import BlobImage from "../common/BlobImage"; @@ -52,15 +54,9 @@ const TimelineMemberItem: React.FC<{ ); }; -export interface TimelineMemberCallbacks { - onCheckUser: (username: string) => Promise; - onAddUser: (user: User) => Promise; - onRemoveUser: (username: string) => void; -} - export interface TimelineMemberProps { - members: User[]; - edit: TimelineMemberCallbacks | null | undefined; + timeline: TimelineInfo; + editable: boolean; } const TimelineMember: React.FC = (props) => { @@ -81,7 +77,9 @@ const TimelineMember: React.FC = (props) => { userSearchState.type === "user" ? userSearchState.data.username : undefined ); - const members = props.members; + const { timeline } = props; + + const members = [timeline.owner, ...timeline.members]; return ( @@ -91,13 +89,21 @@ const TimelineMember: React.FC = (props) => { key={member.username} user={member} owner={index === 0} - onRemove={props.edit?.onRemoveUser} + onRemove={ + props.editable + ? () => { + void timelineService.removeMember( + timeline.name, + member.username + ); + } + : undefined + } /> ))} {(() => { - const edit = props.edit; - if (edit != null) { + if (props.editable) { return ( <> = (props) => { }); return; } - setUserSearchState({ type: "loading" }); - edit.onCheckUser(userSearchText).then( - (u) => { - if (u == null) { + getHttpUserClient() + .get(userSearchText) + .catch((e) => { + if (e instanceof HttpUserNotExistError) { + return null; + } else { + throw e; + } + }) + .then( + (u) => { + if (u == null) { + setUserSearchState({ + type: "error", + data: "timeline.userNotExist", + }); + } else { + setUserSearchState({ type: "user", data: u }); + } + }, + (e) => { setUserSearchState({ type: "error", - data: "timeline.userNotExist", + data: `${e as string}`, }); - } else { - setUserSearchState({ type: "user", data: u }); } - }, - (e) => { - setUserSearchState({ - type: "error", - data: `${e as string}`, - }); - } - ); + ); }} /> {(() => { @@ -166,10 +180,12 @@ const TimelineMember: React.FC = (props) => { className="align-self-center" disabled={!addable} onClick={() => { - void edit.onAddUser(u).then((_) => { - setUserSearchText(""); - setUserSearchState({ type: "init" }); - }); + void timelineService + .addMember(timeline.name, u.username) + .then(() => { + setUserSearchText(""); + setUserSearchState({ type: "init" }); + }); }} > {t("timeline.member.add")} diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index f5dec67c..f8b2b38b 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -7,7 +7,6 @@ import { useUser } from "@/services/user"; import { timelineService, usePosts, useTimeline } from "@/services/timeline"; import { getHttpBookmarkClient } from "@/http/bookmark"; import { getHttpHighlightClient } from "@/http/highlight"; -import { getHttpUserClient, HttpUserNotExistError } from "@/http/user"; import { TimelineMemberDialog } from "./TimelineMember"; import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; @@ -203,30 +202,8 @@ export default function TimelinePageTemplate( { - return getHttpUserClient() - .get(u) - .catch((e) => { - if (e instanceof HttpUserNotExistError) { - return null; - } else { - throw e; - } - }); - }, - onAddUser: (u) => { - return service.addMember(name, u.username).toPromise().then(); - }, - onRemoveUser: (u) => { - service.removeMember(name, u); - }, - } - : null - } + timeline={timeline} + editable={service.hasManagePermission(user, timeline)} /> ); } -- cgit v1.2.3