aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/app/services
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2021-01-11 21:34:57 +0800
committercrupest <crupest@outlook.com>2021-01-11 21:34:57 +0800
commit873bb613bc2deb86a4266bac160d14be265f9609 (patch)
tree00aeea22bfef21629a1004c174a88c931a47a5ca /FrontEnd/src/app/services
parent1488919a75a67ad3992e9c66031c9079c50053f2 (diff)
downloadtimeline-873bb613bc2deb86a4266bac160d14be265f9609.tar.gz
timeline-873bb613bc2deb86a4266bac160d14be265f9609.tar.bz2
timeline-873bb613bc2deb86a4266bac160d14be265f9609.zip
...
Diffstat (limited to 'FrontEnd/src/app/services')
-rw-r--r--FrontEnd/src/app/services/DataHub.ts225
-rw-r--r--FrontEnd/src/app/services/DataHub2.ts10
-rw-r--r--FrontEnd/src/app/services/common.ts19
-rw-r--r--FrontEnd/src/app/services/timeline.ts573
-rw-r--r--FrontEnd/src/app/services/user.ts140
5 files changed, 272 insertions, 695 deletions
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<TData> = (data: TData) => void;
-
-export type WithSyncStatus<T> = T & { syncing: boolean };
-
-export class DataLine<TData> {
- private _current: TData | undefined = undefined;
-
- private _syncPromise: Promise<void> | null = null;
- private _syncingSubject = new BehaviorSubject<boolean>(false);
-
- private _observers: Subscriber<TData>[] = [];
-
- constructor(
- private config: {
- sync: () => Promise<void>;
- destroyable?: (value: TData | undefined) => boolean;
- disableInitSync?: boolean;
- }
- ) {
- if (config.disableInitSync !== true) {
- setTimeout(() => void this.sync());
- }
- }
-
- private subscribe(subscriber: Subscriber<TData>): void {
- this._observers.push(subscriber);
- if (this._current !== undefined) {
- subscriber(this._current);
- }
- }
-
- private unsubscribe(subscriber: Subscriber<TData>): void {
- if (!this._observers.includes(subscriber)) return;
- pull(this._observers, subscriber);
- }
-
- getObservable(): Observable<TData> {
- return new Observable<TData>((observer) => {
- const f = (data: TData): void => {
- observer.next(data);
- };
- this.subscribe(f);
-
- return () => {
- this.unsubscribe(f);
- };
- });
- }
-
- getSyncStatusObservable(): Observable<boolean> {
- return this._syncingSubject.asObservable();
- }
-
- getDataWithSyncStatusObservable(): Observable<WithSyncStatus<TData>> {
- 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<void> {
- 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<TData>) => Promise<void>
- ): Promise<void> {
- 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<TKey, TData> {
- private sync: (key: TKey, line: DataLine<TData>) => Promise<void>;
- private keyToString: (key: TKey) => string;
- private destroyable?: (key: TKey, value: TData | undefined) => boolean;
-
- private readonly subscriptionLineMap = new Map<string, DataLine<TData>>();
-
- 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<TData>) => Promise<void>;
- 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<TData> {
- const keyString = this.keyToString(key);
- const { destroyable } = this;
- const newLine: DataLine<TData> = new DataLine<TData>({
- 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<TData> {
- return this.getLineOrCreate(key).getObservable();
- }
-
- getSyncStatusObservable(key: TKey): Observable<boolean> {
- return this.getLineOrCreate(key).getSyncStatusObservable();
- }
-
- getDataWithSyncStatusObservable(
- key: TKey
- ): Observable<WithSyncStatus<TData>> {
- return this.getLineOrCreate(key).getDataWithSyncStatusObservable();
- }
-
- getLine(key: TKey): DataLine<TData> | null {
- const keyString = this.keyToString(key);
- return this.subscriptionLineMap.get(keyString) ?? null;
- }
-
- getLineOrCreate(key: TKey): DataLine<TData> {
- const keyString = this.keyToString(key);
- return this.subscriptionLineMap.get(keyString) ?? this.createLine(key);
- }
-
- getLineOrCreateWithoutInitSync(key: TKey): DataLine<TData> {
- const keyString = this.keyToString(key);
- return (
- this.subscriptionLineMap.get(keyString) ?? this.createLine(key, true)
- );
- }
-
- optionalInitLineWithSyncAction(
- key: TKey,
- syncAction: (line: DataLine<TData>) => Promise<void>
- ): Promise<void> {
- 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<TData> = (data: TData) => void;
export interface DataAndStatus<TData> {
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<number | null>("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<HttpTimelinePostInfo, "content"> & {
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<HttpTimelineInfo, "owner" | "members"> & {
owner: string;
members: string[];
};
-type TimelinePostData = Omit<HttpTimelinePostInfo, "author"> & {
+type TimelinePostData = Omit<TimelinePostInfo, "author"> & {
author: string;
};
-export class TimelineService {
- private getCachedTimeline(
- timelineName: string
- ): Promise<TimelineData | null> {
- return dataStorage.getItem<TimelineData | null>(`timeline.${timelineName}`);
- }
-
- private saveTimeline(
- timelineName: string,
- data: TimelineData
- ): Promise<void> {
- return dataStorage
- .setItem<TimelineData>(`timeline.${timelineName}`, data)
- .then();
- }
+interface TimelinePostsData {
+ lastUpdated: Date;
+ posts: TimelinePostData[];
+}
+export class TimelineService {
private async clearTimelineData(timelineName: string): Promise<void> {
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<string, HttpTimelineInfo | "notexist">({
+ 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<TimelineData>(
+ this.generateTimelineDataStorageKey(timelineName),
+ this.convertHttpTimelineToData(data)
+ );
+ },
+ getSavedData: async (timelineName) => {
+ const savedData = await dataStorage.getItem<TimelineData | null>(
+ 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<void> {
- return this._timelineHub.getLineOrCreate(timelineName).sync();
- }
-
- getTimeline$(timelineName: string): Observable<TimelineWithSyncStatus> {
- 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<TimelineInfo> {
@@ -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<TimelinePostData[] | null> {
- return dataStorage.getItem<TimelinePostData[] | null>(
- `timeline.${timelineName}.posts`
- );
- }
+ const savedData: TimelinePostsData = {
+ ...data,
+ posts: data.posts.map((p) => ({ ...p, author: p.author.username })),
+ };
- private savePosts(
- timelineName: string,
- data: TimelinePostData[]
- ): Promise<void> {
- return dataStorage
- .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data)
- .then();
- }
+ data.posts.forEach((p) => {
+ userInfoService.saveUser(p.author);
+ });
- private syncPosts(timelineName: string): Promise<void> {
- return this._postsHub.getLineOrCreate(timelineName).sync();
- }
+ await dataStorage.setItem<TimelinePostsData>(
+ this.generatePostsDataStorageKey(timelineName),
+ savedData
+ );
+ },
+ getSavedData: async (timelineName) => {
+ const savedData = await dataStorage.getItem<TimelinePostsData | null>(
+ 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<TimelinePostInfo> => {
+ 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<TimelinePostInfo[]> =>
+ Promise.all(posts.map((p) => convert(p)));
- const lastUpdatedTime = await dataStorage.getItem<Date | null>(
- `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<Date>(`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<Date>(`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<TimelinePostsWithSyncState> {
- 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<BlobWithEtag | null> {
- return dataStorage.getItem<BlobWithEtag | null>(
- `timeline.${key.timelineName}.post.${key.postId}.data`
- );
- }
-
- private savePostData(
- key: {
- timelineName: string;
- postId: number;
- },
- data: BlobWithEtag
- ): Promise<void> {
- return dataStorage
- .setItem<BlobWithEtag>(
- `timeline.${key.timelineName}.post.${key.postId}.data`,
- data
- )
- .then();
- }
-
- private syncPostData(key: {
- timelineName: string;
- postId: number;
- }): Promise<void> {
- 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<Blob | null> {
- return this._getCachedPostData({ timelineName, postId }).then(
- (d) => d?.data ?? null
- );
- }
-
- getPostData$(timelineName: string, postId: number): Observable<BlobOrStatus> {
- 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<React.SetStateAction<TimelineWithSyncStatus | undefined>>
+ DataAndStatus<TimelineInfo | "notexist">,
+ React.Dispatch<React.SetStateAction<DataAndStatus<TimelineInfo | "notexist">>>
] {
- const [state, setState] = React.useState<TimelineWithSyncStatus | undefined>(
- undefined
- );
+ const [state, setState] = React.useState<
+ DataAndStatus<TimelineInfo | "notexist">
+ >({
+ 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<TimelinePostsInfo | "notexist" | "forbid"> {
const [state, setState] = React.useState<
- TimelinePostsWithSyncState | undefined
- >(undefined);
+ DataAndStatus<TimelinePostsInfo | "notexist" | "forbid">
+ >({ 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<HttpUser | null> {
+ const user = await this.userHub.getLine(username).getSavedData();
+ if (user == null || user === "notexist") return null;
+ return user;
+ }
+
+ async getCachedUsers(usernames: string[]): Promise<HttpUser[] | null> {
+ 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<BlobWithEtag | null> {
- return dataStorage.getItem<BlobWithEtag | null>(`user.${username}.avatar`);
- }
-
- private saveAvatar(username: string, data: BlobWithEtag): Promise<void> {
- return dataStorage
- .setItem<BlobWithEtag>(`user.${username}.avatar`, data)
- .then();
- }
-
- getCachedAvatar(username: string): Promise<Blob | null> {
- return this._getCachedAvatar(username).then((d) => d?.data ?? null);
+ private generateAvatarDataStorageKey(username: string): string {
+ return `user.${username}.avatar`;
}
- syncAvatar(username: string): Promise<void> {
- 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<string, BlobWithEtag | "notexist">({
+ saveData: async (username, data) => {
+ if (typeof data === "string") return;
+ await dataStorage.setItem<BlobWithEtag>(
+ this.generateAvatarDataStorageKey(username),
+ data
+ );
+ },
+ getSavedData: (username) =>
+ dataStorage.getItem<BlobWithEtag | null>(
+ 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<Blob> {
- return this._avatarHub.getObservable(username).pipe(
- map((state) => state.data),
- filter((blob): blob is Blob => blob != null)
- );
- }
-
- getUserInfo(username: string): Observable<User> {
- return from(getHttpUserClient().get(username)).pipe(
- convertError(HttpUserNotExistError, UserNotExistError)
- );
- }
-
async setAvatar(username: string, blob: Blob): Promise<void> {
- 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<void> {
@@ -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;
}