aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-08-04 18:14:41 +0800
committerGitHub <noreply@github.com>2020-08-04 18:14:41 +0800
commit0791682e1f333adb5c79b979e3e8c5c50bb5f85a (patch)
tree47b85b9bfb5026743a4a9984c5c24b8363e8a5af
parent66417650b46f27f8238cc6997e2ff14579c1244a (diff)
parent89806d334fe2c7ef0ea8b3d12c74759e8e3ba860 (diff)
downloadtimeline-0791682e1f333adb5c79b979e3e8c5c50bb5f85a.tar.gz
timeline-0791682e1f333adb5c79b979e3e8c5c50bb5f85a.tar.bz2
timeline-0791682e1f333adb5c79b979e3e8c5c50bb5f85a.zip
Merge pull request #132 from crupest/refactor
Refactor a lot of things.
-rw-r--r--Timeline/ClientApp/src/app/common/AppBar.tsx9
-rw-r--r--Timeline/ClientApp/src/app/common/BlobImage.tsx27
-rw-r--r--Timeline/ClientApp/src/app/data/SubscriptionHub.ts183
-rw-r--r--Timeline/ClientApp/src/app/data/common.ts5
-rw-r--r--Timeline/ClientApp/src/app/data/timeline.ts778
-rw-r--r--Timeline/ClientApp/src/app/data/user.ts52
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx9
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineItem.tsx24
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelineMember.tsx13
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx85
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx10
-rw-r--r--Timeline/ClientApp/src/app/user/UserInfoCard.tsx9
12 files changed, 588 insertions, 616 deletions
diff --git a/Timeline/ClientApp/src/app/common/AppBar.tsx b/Timeline/ClientApp/src/app/common/AppBar.tsx
index 061ba08c..8349aef7 100644
--- a/Timeline/ClientApp/src/app/common/AppBar.tsx
+++ b/Timeline/ClientApp/src/app/common/AppBar.tsx
@@ -5,14 +5,15 @@ import { Navbar, NavbarToggler, Collapse, Nav, NavItem } from 'reactstrap';
import { useMediaQuery } from 'react-responsive';
import { useTranslation } from 'react-i18next';
-import { useUser, useAvatarUrl } from '../data/user';
+import { useUser, useAvatar } from '../data/user';
import TimelineLogo from './TimelineLogo';
+import BlobImage from './BlobImage';
const AppBar: React.FC = (_) => {
const history = useHistory();
const user = useUser();
- const avatarUrl = useAvatarUrl(user?.username);
+ const avatar = useAvatar(user?.username);
const { t } = useTranslation();
@@ -34,9 +35,9 @@ const AppBar: React.FC = (_) => {
<div className="ml-auto mr-2">
{user != null ? (
<NavLink to={`/users/${user.username}`}>
- <img
+ <BlobImage
className="avatar small rounded-circle bg-white"
- src={avatarUrl}
+ blob={avatar}
/>
</NavLink>
) : (
diff --git a/Timeline/ClientApp/src/app/common/BlobImage.tsx b/Timeline/ClientApp/src/app/common/BlobImage.tsx
new file mode 100644
index 00000000..91497705
--- /dev/null
+++ b/Timeline/ClientApp/src/app/common/BlobImage.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+import { ExcludeKey } from '../utilities/type';
+
+const BlobImage: React.FC<
+ ExcludeKey<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & { blob?: Blob }
+> = (props) => {
+ const { blob, ...otherProps } = props;
+
+ const [url, setUrl] = React.useState<string | undefined>(undefined);
+
+ React.useEffect(() => {
+ if (blob != null) {
+ const url = URL.createObjectURL(blob);
+ setUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setUrl(undefined);
+ }
+ }, [blob]);
+
+ return <img {...otherProps} src={url} />;
+};
+
+export default BlobImage;
diff --git a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts
index 406d293f..87592da6 100644
--- a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts
+++ b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts
@@ -1,13 +1,10 @@
// Remarks for SubscriptionHub:
// 1. Compared with 'push' sematics in rxjs subject, we need 'pull'. In other words, no subscription, no updating.
-// 2. We need a way to finalize the last object. For example, if it has an object url, we need to revoke it.
-// 3. Make api easier to use and write less boilerplate codes.
-//
-// Currently updator will wait for last update or creation to finish. So the old data passed to it will always be right. We may add feature for just cancel last one but not wait for it.
+// 2. Make api easier to use and write less boilerplate codes.
//
// There might be some bugs, especially memory leaks and in asynchronization codes.
-import * as rxjs from 'rxjs';
+import { pull } from 'lodash';
export type Subscriber<TData> = (data: TData) => void;
@@ -19,60 +16,40 @@ export class Subscription {
}
}
-class SubscriptionToken {
- constructor(public _subscription: rxjs.Subscription) {}
-}
+export class NoValue {}
+
+export class SubscriptionLine<TData> {
+ private _current: TData | NoValue = new NoValue();
-class SubscriptionLine<TData> {
- private _lastDataPromise: Promise<TData>;
- private _dataSubject: rxjs.BehaviorSubject<TData>;
- private _refCount = 0;
-
- constructor(
- defaultValueProvider: () => TData,
- setup: ((old: TData) => Promise<TData>) | undefined,
- private _destroyer: ((data: TData) => void) | undefined,
- private _onZeroRef: (self: SubscriptionLine<TData>) => void
- ) {
- const initValue = defaultValueProvider();
- this._lastDataPromise = Promise.resolve(initValue);
- this._dataSubject = new rxjs.BehaviorSubject<TData>(initValue);
- if (setup != null) {
- this.next(setup);
+ private _observers: Subscriber<TData>[] = [];
+
+ constructor(private config?: { onZeroObserver?: () => void }) {}
+
+ subscribe(subscriber: Subscriber<TData>): Subscription {
+ this._observers.push(subscriber);
+ if (!(this._current instanceof NoValue)) {
+ subscriber(this._current);
}
+ return new Subscription(() => this.unsubscribe(subscriber));
}
- subscribe(subscriber: Subscriber<TData>): SubscriptionToken {
- const subscription = this._dataSubject.subscribe(subscriber);
- this._refCount += 1;
- return new SubscriptionToken(subscription);
+ private unsubscribe(subscriber: Subscriber<TData>): void {
+ if (!this._observers.includes(subscriber)) return;
+ pull(this._observers, subscriber);
+ if (this._observers.length === 0) {
+ this?.config?.onZeroObserver?.();
+ }
}
- unsubscribe(token: SubscriptionToken): void {
- token._subscription.unsubscribe();
- this._refCount -= 1;
- if (this._refCount === 0) {
- const { _destroyer: destroyer } = this;
- if (destroyer != null) {
- void this._lastDataPromise.then((data) => {
- destroyer(data);
- });
- }
- this._onZeroRef(this);
- }
+ next(value: TData): void {
+ this._current = value;
+ this._observers.forEach((observer) => observer(value));
}
- next(updator: (old: TData) => Promise<TData>): void {
- this._lastDataPromise = this._lastDataPromise
- .then((old) => updator(old))
- .then((data) => {
- const last = this._dataSubject.value;
- if (this._destroyer != null) {
- this._destroyer(last);
- }
- this._dataSubject.next(data);
- return data;
- });
+ nextWithOld(updator: (old: TData | NoValue) => TData): void {
+ const value = updator(this._current);
+ this._current = value;
+ this._observers.forEach((observer) => observer(value));
}
}
@@ -82,53 +59,87 @@ export interface ISubscriptionHub<TKey, TData> {
export class SubscriptionHub<TKey, TData>
implements ISubscriptionHub<TKey, TData> {
- // If setup is set, update is called with setup immediately after setting default value.
- constructor(
- public keyToString: (key: TKey) => string,
- public defaultValueProvider: (key: TKey) => TData,
- public setup?: (key: TKey) => Promise<TData>,
- public destroyer?: (key: TKey, data: TData) => void
- ) {}
+ private keyToString: (key: TKey) => string;
+ private setup?: (
+ key: TKey,
+ next: (value: TData) => void,
+ line: SubscriptionLine<TData>
+ ) => (() => void) | void;
- private subscriptionLineMap = new Map<string, SubscriptionLine<TData>>();
+ private readonly subscriptionLineMap = new Map<
+ string,
+ {
+ line: SubscriptionLine<TData>;
+ destroyer: (() => void) | undefined;
+ destroyTimer?: number; // Cancel it when resubscribe.
+ }
+ >();
+
+ // 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?: {
+ keyToString?: (key: TKey) => string;
+ setup?: (key: TKey, next: (value: TData) => void) => (() => void) | void;
+ }) {
+ this.keyToString =
+ config?.keyToString ??
+ ((value): string => {
+ if (typeof value === 'string') return value;
+ else
+ throw new Error(
+ 'Default keyToString function only pass string value.'
+ );
+ });
+
+ this.setup = config?.setup;
+ }
subscribe(key: TKey, subscriber: Subscriber<TData>): Subscription {
const keyString = this.keyToString(key);
const line = (() => {
- const savedLine = this.subscriptionLineMap.get(keyString);
- if (savedLine == null) {
- const { setup, destroyer } = this;
- const newLine = new SubscriptionLine<TData>(
- () => this.defaultValueProvider(key),
- setup != null ? () => setup(key) : undefined,
- destroyer != null
- ? (data) => {
- destroyer(key, data);
- }
- : undefined,
- () => {
- this.subscriptionLineMap.delete(keyString);
- }
- );
- this.subscriptionLineMap.set(keyString, newLine);
+ const info = this.subscriptionLineMap.get(keyString);
+ if (info == null) {
+ const { setup } = this;
+ const newLine = new SubscriptionLine<TData>({
+ onZeroObserver: () => {
+ const i = this.subscriptionLineMap.get(keyString);
+ if (i != null) {
+ i.destroyTimer = window.setTimeout(() => {
+ i.destroyer?.();
+ this.subscriptionLineMap.delete(keyString);
+ }, 10000);
+ }
+ },
+ });
+ const destroyer = setup?.(key, newLine.next.bind(newLine), newLine);
+ this.subscriptionLineMap.set(keyString, {
+ line: newLine,
+ destroyer: destroyer != null ? destroyer : undefined,
+ });
return newLine;
} else {
- return savedLine;
+ if (info.destroyTimer != null) {
+ window.clearTimeout(info.destroyTimer);
+ info.destroyTimer = undefined;
+ }
+ return info.line;
}
})();
- const token = line.subscribe(subscriber);
- return new Subscription(() => {
- line.unsubscribe(token);
- });
+ return line.subscribe(subscriber);
+ }
+
+ update(key: TKey, value: TData): void {
+ const keyString = this.keyToString(key);
+ const info = this.subscriptionLineMap.get(keyString);
+ if (info != null) {
+ info.line.next(value);
+ }
}
- // Old data is destroyed automatically.
- // updator is called only if there is subscription.
- update(key: TKey, updator: (key: TKey, old: TData) => Promise<TData>): void {
+ updateWithOld(key: TKey, updator: (old: TData | NoValue) => TData): void {
const keyString = this.keyToString(key);
- const line = this.subscriptionLineMap.get(keyString);
- if (line != null) {
- line.next((old) => updator(key, old));
+ const info = this.subscriptionLineMap.get(keyString);
+ if (info != null) {
+ info.line.nextWithOld(updator);
}
}
}
diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts
index 9f985ce6..786279f2 100644
--- a/Timeline/ClientApp/src/app/data/common.ts
+++ b/Timeline/ClientApp/src/app/data/common.ts
@@ -6,11 +6,6 @@ export const dataStorage = localforage.createInstance({
driver: localforage.INDEXEDDB,
});
-export interface BlobWithUrl {
- blob: Blob;
- url: string;
-}
-
export class ForbiddenError extends Error {
constructor(message?: string) {
super(message);
diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts
index b30f3a7d..fb8a3874 100644
--- a/Timeline/ClientApp/src/app/data/timeline.ts
+++ b/Timeline/ClientApp/src/app/data/timeline.ts
@@ -6,10 +6,10 @@ import { pull } from 'lodash';
import { convertError } from '../utilities/rxjs';
-import { BlobWithUrl, dataStorage, ForbiddenError } from './common';
-import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub';
+import { dataStorage } from './common';
+import { SubscriptionHub, ISubscriptionHub, NoValue } from './SubscriptionHub';
-import { UserAuthInfo, checkLogin, userService } from './user';
+import { UserAuthInfo, checkLogin, userService, userInfoService } from './user';
export { kTimelineVisibilities } from '../http/timeline';
@@ -24,15 +24,13 @@ import {
HttpTimelinePostPostRequestTextContent,
HttpTimelinePostPostRequestImageContent,
HttpTimelinePostInfo,
- HttpTimelinePostContent,
HttpTimelinePostTextContent,
- HttpTimelinePostImageContent,
getHttpTimelineClient,
HttpTimelineNotExistError,
HttpTimelineNameConflictError,
- HttpTimelineGenericPostInfo,
} from '../http/timeline';
-import { BlobWithEtag, NotModified } from '../http/common';
+import { BlobWithEtag, NotModified, HttpNetworkError } from '../http/common';
+import { HttpUser } from '../http/user';
export type TimelineInfo = HttpTimelineInfo;
export type TimelineChangePropertyRequest = HttpTimelinePatchRequest;
@@ -41,13 +39,24 @@ export type TimelineCreatePostContent = HttpTimelinePostPostRequestContent;
export type TimelineCreatePostTextContent = HttpTimelinePostPostRequestTextContent;
export type TimelineCreatePostImageContent = HttpTimelinePostPostRequestImageContent;
-export interface TimelinePostInfo extends HttpTimelinePostInfo {
- timelineName: string;
+export type TimelinePostTextContent = HttpTimelinePostTextContent;
+
+export interface TimelinePostImageContent {
+ type: 'image';
+ data: Blob;
}
-export type TimelinePostContent = HttpTimelinePostContent;
-export type TimelinePostTextContent = HttpTimelinePostTextContent;
-export type TimelinePostImageContent = HttpTimelinePostImageContent;
+export type TimelinePostContent =
+ | TimelinePostTextContent
+ | TimelinePostImageContent;
+
+export interface TimelinePostInfo {
+ id: number;
+ content: TimelinePostContent;
+ time: Date;
+ lastUpdated: Date;
+ author: HttpUser;
+}
export const timelineVisibilityTooltipTranslationMap: Record<
TimelineVisibility,
@@ -61,45 +70,32 @@ export const timelineVisibilityTooltipTranslationMap: Record<
export class TimelineNotExistError extends Error {}
export class TimelineNameConflictError extends Error {}
-export interface PostKey {
- timelineName: string;
- postId: number;
-}
+export type TimelineWithSyncState =
+ | {
+ syncState:
+ | 'offline' // Sync failed and use cache. Null timeline means no cache.
+ | 'synced'; // Sync succeeded. Null timeline means the timeline does not exist.
+ timeline: TimelineInfo | null;
+ }
+ | {
+ syncState: 'new'; // This is a new timeline different from cached one.
+ timeline: TimelineInfo;
+ };
-export interface TimelinePostListState {
+export interface TimelinePostsWithSyncState {
state:
- | 'loading' // Loading posts from cache. `posts` is empty array.
| 'forbid' // The list is forbidden to see.
- | 'syncing' // Cache loaded and syncing now.
| 'synced' // Sync succeeded.
| 'offline'; // Sync failed and use cache.
posts: TimelinePostInfo[];
}
-export interface TimelineInfoLoadingState {
- state: 'loading'; // Loading from cache.
- timeline: null;
-}
-
-export interface TimelineInfoNonLoadingState {
- state:
- | 'syncing' // Cache loaded and syncing now. If null means there is no cache for the timeline.
- | 'offline' // Sync failed and use cache.
- | 'synced' // Sync succeeded. If null means the timeline does not exist.
- | 'new'; // This is a new timeline different from cached one. If null means the timeline does not exist.
- timeline: TimelineInfo | null;
-}
-
-export type TimelineInfoState =
- | TimelineInfoLoadingState
- | TimelineInfoNonLoadingState;
-
interface TimelineCache {
timeline: TimelineInfo;
lastUpdated: string;
}
-interface PostListInfo {
+interface PostsInfoCache {
idList: number[];
lastUpdated: string;
}
@@ -112,43 +108,35 @@ export class TimelineService {
return `timeline.${timelineName}`;
}
- private getCachedTimeline(
+ private async fetchAndCacheTimeline(
timelineName: string
- ): Promise<TimelineInfo | null> {
- return dataStorage
- .getItem<TimelineCache | null>(this.getTimelineKey(timelineName))
- .then((cache) => cache?.timeline ?? null);
- }
-
- private async syncTimeline(timelineName: string): Promise<TimelineInfo> {
+ ): Promise<
+ | { timeline: TimelineInfo; type: 'new' | 'cache' | 'synced' }
+ | 'offline'
+ | 'notexist'
+ > {
const cache = await dataStorage.getItem<TimelineCache | null>(timelineName);
+ const key = this.getTimelineKey(timelineName);
const save = (cache: TimelineCache): Promise<TimelineCache> =>
- dataStorage.setItem<TimelineCache>(
- this.getTimelineKey(timelineName),
- cache
- );
- const push = (state: TimelineInfoState): void => {
- this._timelineSubscriptionHub.update(timelineName, () =>
- Promise.resolve(state)
- );
- };
+ dataStorage.setItem<TimelineCache>(key, cache);
- let result: TimelineInfo;
const now = new Date();
if (cache == null) {
try {
- const res = await getHttpTimelineClient().getTimeline(timelineName);
- result = res;
- await save({ timeline: result, lastUpdated: now.toISOString() });
- push({ state: 'synced', timeline: result });
+ const timeline = await getHttpTimelineClient().getTimeline(
+ timelineName
+ );
+ await save({ timeline, lastUpdated: now.toISOString() });
+ return { timeline, type: 'synced' };
} catch (e) {
if (e instanceof HttpTimelineNotExistError) {
- push({ state: 'synced', timeline: null });
+ return 'notexist';
+ } else if (e instanceof HttpNetworkError) {
+ return 'offline';
} else {
- push({ state: 'offline', timeline: null });
+ throw e;
}
- throw e;
}
} else {
try {
@@ -157,72 +145,58 @@ export class TimelineService {
ifModifiedSince: new Date(cache.lastUpdated),
});
if (res instanceof NotModified) {
- result = cache.timeline;
- await save({ timeline: result, lastUpdated: now.toISOString() });
- push({ state: 'synced', timeline: result });
+ const { timeline } = cache;
+ await save({ timeline, lastUpdated: now.toISOString() });
+ return { timeline, type: 'synced' };
} else {
- result = res;
- await save({ timeline: result, lastUpdated: now.toISOString() });
+ const timeline = res;
+ await save({ timeline, lastUpdated: now.toISOString() });
if (res.uniqueId === cache.timeline.uniqueId) {
- push({ state: 'synced', timeline: result });
+ return { timeline, type: 'synced' };
} else {
- push({ state: 'new', timeline: result });
+ return { timeline, type: 'new' };
}
}
} catch (e) {
if (e instanceof HttpTimelineNotExistError) {
- push({ state: 'new', timeline: null });
- } else {
- push({ state: 'offline', timeline: cache.timeline });
+ await dataStorage.removeItem(key);
+ return 'notexist';
+ } else if (e instanceof HttpNetworkError) {
+ return { timeline: cache.timeline, type: 'cache' };
}
throw e;
}
}
- return result;
}
private _timelineSubscriptionHub = new SubscriptionHub<
string,
- TimelineInfoState
- >(
- (key) => key,
- () => ({
- state: 'loading',
- timeline: null,
- }),
- async (key) => {
- const result = await this.getCachedTimeline(key);
- void this.syncTimeline(key);
- return {
- state: 'syncing',
- timeline: result,
- };
- }
- );
+ TimelineWithSyncState
+ >({
+ setup: (key, next) => {
+ void this.fetchAndCacheTimeline(key).then((result) => {
+ if (result === 'offline') {
+ next({ syncState: 'offline', timeline: null });
+ } else if (result === 'notexist') {
+ next({ syncState: 'synced', timeline: null });
+ } else {
+ const { type, timeline } = result;
+ if (type === 'cache') {
+ next({ syncState: 'offline', timeline });
+ } else if (type === 'synced') {
+ next({ syncState: 'synced', timeline });
+ } else {
+ next({ syncState: 'new', timeline });
+ }
+ }
+ });
+ },
+ });
- get timelineHub(): ISubscriptionHub<string, TimelineInfoState> {
+ get timelineHub(): ISubscriptionHub<string, TimelineWithSyncState> {
return this._timelineSubscriptionHub;
}
- // TODO: Remove this! This is currently only used to avoid multiple fetch of timeline. Because post list need to use the timeline id and call this method. But after timeline is also saved locally, this should be removed.
- private timelineCache = new Map<string, Promise<TimelineInfo>>();
-
- // TODO: Remove this.
- getTimeline(timelineName: string): Observable<TimelineInfo> {
- const cache = this.timelineCache.get(timelineName);
- let promise: Promise<TimelineInfo>;
- if (cache == null) {
- promise = getHttpTimelineClient().getTimeline(timelineName);
- this.timelineCache.set(timelineName, promise);
- } else {
- promise = cache;
- }
-
- return from(promise).pipe(
- convertError(HttpTimelineNotExistError, TimelineNotExistError)
- );
- }
-
createTimeline(timelineName: string): Observable<TimelineInfo> {
const user = checkLogin();
return from(
@@ -243,7 +217,15 @@ export class TimelineService {
): Observable<TimelineInfo> {
const user = checkLogin();
return from(
- getHttpTimelineClient().patchTimeline(timelineName, req, user.token)
+ getHttpTimelineClient()
+ .patchTimeline(timelineName, req, user.token)
+ .then((timeline) => {
+ this._timelineSubscriptionHub.update(timelineName, {
+ syncState: 'synced',
+ timeline,
+ });
+ return timeline;
+ })
);
}
@@ -257,36 +239,57 @@ export class TimelineService {
addMember(timelineName: string, username: string): Observable<unknown> {
const user = checkLogin();
return from(
- getHttpTimelineClient().memberPut(timelineName, username, user.token)
+ getHttpTimelineClient()
+ .memberPut(timelineName, username, user.token)
+ .then(() => {
+ userInfoService.getUserInfo(username).subscribe((newUser) => {
+ this._timelineSubscriptionHub.updateWithOld(timelineName, (old) => {
+ if (old instanceof NoValue || old.timeline == null)
+ throw new Error('Timeline not loaded.');
+
+ return {
+ ...old,
+ timeline: {
+ ...old.timeline,
+ members: [...old.timeline.members, newUser],
+ },
+ };
+ });
+ });
+ })
);
}
removeMember(timelineName: string, username: string): Observable<unknown> {
const user = checkLogin();
return from(
- getHttpTimelineClient().memberDelete(timelineName, username, user.token)
- );
- }
+ getHttpTimelineClient()
+ .memberDelete(timelineName, username, user.token)
+ .then(() => {
+ this._timelineSubscriptionHub.updateWithOld(timelineName, (old) => {
+ if (old instanceof NoValue || old.timeline == null)
+ throw new Error('Timeline not loaded.');
- // TODO: Remove this.
- getPosts(timelineName: string): Observable<TimelinePostInfo[]> {
- const token = userService.currentUser?.token;
- return from(getHttpTimelineClient().listPost(timelineName, token)).pipe(
- map((posts) => {
- return posts.map((post) => ({
- ...post,
- timelineName,
- }));
- })
+ return {
+ ...old,
+ timeline: {
+ ...old.timeline,
+ members: old.timeline.members.filter(
+ (u) => u.username !== username
+ ),
+ },
+ };
+ });
+ })
);
}
// post list storage structure:
- // each timeline has a PostListInfo saved with key created by getPostListInfoKey
+ // each timeline has a PostsInfoCache saved with key created by getPostsInfoKey
// each post of a timeline has a HttpTimelinePostInfo with key created by getPostKey
// each post with data has BlobWithEtag with key created by getPostDataKey
- private getPostListInfoKey(timelineUniqueId: string): string {
+ private getPostsInfoKey(timelineUniqueId: string): string {
return `timeline.${timelineUniqueId}.postListInfo`;
}
@@ -298,274 +301,262 @@ export class TimelineService {
return `timeline.${timelineUniqueId}.post.${id}.data`;
}
- private async getCachedPostList(
- timelineName: string
- ): Promise<TimelinePostInfo[]> {
- const timeline = await this.getTimeline(timelineName).toPromise();
- if (!this.hasReadPermission(userService.currentUser, timeline)) {
- throw new ForbiddenError(
- 'You are not allowed to get posts of this timeline.'
- );
- }
-
- const postListInfo = await dataStorage.getItem<PostListInfo | null>(
- this.getPostListInfoKey(timeline.uniqueId)
- );
- if (postListInfo == null) {
- return [];
+ private convertPost = async (
+ post: HttpTimelinePostInfo,
+ dataProvider: () => Promise<Blob | null | undefined>
+ ): Promise<TimelinePostInfo> => {
+ const { content } = post;
+ if (content.type === 'text') {
+ return {
+ ...post,
+ content,
+ };
} else {
- return (
- await Promise.all(
- postListInfo.idList.map((postId) =>
- dataStorage.getItem<HttpTimelinePostInfo>(
- this.getPostKey(timeline.uniqueId, postId)
- )
- )
- )
- ).map((post) => ({ ...post, timelineName }));
+ const data = await dataProvider();
+ if (data == null) throw new Error('This post requires data.');
+ return {
+ ...post,
+ content: {
+ type: 'image',
+ data,
+ },
+ };
}
- }
+ };
- async syncPostList(timelineName: string): Promise<TimelinePostInfo[]> {
- const timeline = await this.getTimeline(timelineName).toPromise();
+ async fetchAndCachePosts(
+ timeline: TimelineInfo
+ ): Promise<
+ | { posts: TimelinePostInfo[]; type: 'synced' | 'cache' }
+ | 'forbid'
+ | 'offline'
+ > {
if (!this.hasReadPermission(userService.currentUser, timeline)) {
- this._postListSubscriptionHub.update(timelineName, () =>
- Promise.resolve({
- state: 'forbid',
- posts: [],
- })
- );
- throw new ForbiddenError(
- 'You are not allowed to get posts of this timeline.'
- );
+ return 'forbid';
}
- const postListInfoKey = this.getPostListInfoKey(timeline.uniqueId);
- const postListInfo = await dataStorage.getItem<PostListInfo | null>(
- postListInfoKey
+ const postsInfoKey = this.getPostsInfoKey(timeline.uniqueId);
+ const postsInfo = await dataStorage.getItem<PostsInfoCache | null>(
+ postsInfoKey
);
+ const convertPostList = (
+ posts: HttpTimelinePostInfo[],
+ dataProvider: (
+ post: HttpTimelinePostInfo,
+ index: number
+ ) => Promise<Blob | null | undefined>
+ ): Promise<TimelinePostInfo[]> => {
+ return Promise.all(
+ posts.map((post, index) =>
+ this.convertPost(post, () => dataProvider(post, index))
+ )
+ );
+ };
+
const now = new Date();
- let posts: TimelinePostInfo[];
- if (postListInfo == null) {
- let httpPosts: HttpTimelinePostInfo[];
+ if (postsInfo == null) {
try {
- httpPosts = await getHttpTimelineClient().listPost(
- timelineName,
- userService.currentUser?.token
+ const token = userService.currentUser?.token;
+
+ const httpPosts = await getHttpTimelineClient().listPost(
+ timeline.name,
+ token
);
- } catch (e) {
- this._postListSubscriptionHub.update(timelineName, (_, old) =>
- Promise.resolve({
- state: 'offline',
- posts: old.posts,
+
+ const dataList: (BlobWithEtag | null)[] = await Promise.all(
+ httpPosts.map(async (post) => {
+ const { content } = post;
+ if (content.type === 'image') {
+ return await getHttpTimelineClient().getPostData(
+ timeline.name,
+ post.id,
+ token
+ );
+ } else {
+ return null;
+ }
})
);
- throw e;
- }
- await dataStorage.setItem<PostListInfo>(postListInfoKey, {
- idList: httpPosts.map((post) => post.id),
- lastUpdated: now.toISOString(),
- });
+ await dataStorage.setItem<PostsInfoCache>(postsInfoKey, {
+ idList: httpPosts.map((post) => post.id),
+ lastUpdated: now.toISOString(),
+ });
- for (const post of httpPosts) {
- await dataStorage.setItem<HttpTimelinePostInfo>(
- this.getPostKey(timeline.uniqueId, post.id),
- post
+ for (const [i, post] of httpPosts.entries()) {
+ await dataStorage.setItem<HttpTimelinePostInfo>(
+ this.getPostKey(timeline.uniqueId, post.id),
+ post
+ );
+ const data = dataList[i];
+ if (data != null) {
+ await dataStorage.setItem<BlobWithEtag>(
+ this.getPostDataKey(timeline.uniqueId, post.id),
+ data
+ );
+ }
+ }
+
+ const posts: TimelinePostInfo[] = await convertPostList(
+ httpPosts,
+ (post, i) => Promise.resolve(dataList[i]?.data)
);
- }
- posts = httpPosts.map((post) => ({
- ...post,
- timelineName,
- }));
+ return { posts, type: 'synced' };
+ } catch (e) {
+ if (e instanceof HttpNetworkError) {
+ return 'offline';
+ } else {
+ throw e;
+ }
+ }
} else {
- let httpPosts: HttpTimelineGenericPostInfo[];
try {
- httpPosts = await getHttpTimelineClient().listPost(
- timelineName,
- userService.currentUser?.token,
+ const token = userService.currentUser?.token;
+ const httpPosts = await getHttpTimelineClient().listPost(
+ timeline.name,
+ token,
{
- modifiedSince: new Date(postListInfo.lastUpdated),
+ modifiedSince: new Date(postsInfo.lastUpdated),
includeDeleted: true,
}
);
- } catch (e) {
- this._postListSubscriptionHub.update(timelineName, (_, old) =>
- Promise.resolve({
- state: 'offline',
- posts: old.posts,
+
+ const dataList: (BlobWithEtag | null)[] = await Promise.all(
+ httpPosts.map(async (post) => {
+ if (post.deleted) return null;
+ const { content } = post;
+ if (content.type === 'image') {
+ return await getHttpTimelineClient().getPostData(
+ timeline.name,
+ post.id,
+ token
+ );
+ } else {
+ return null;
+ }
})
);
- throw e;
- }
- const newPosts: HttpTimelinePostInfo[] = [];
+ const newPosts: HttpTimelinePostInfo[] = [];
+ const newPostDataList: (BlobWithEtag | null)[] = [];
+
+ for (const [i, post] of httpPosts.entries()) {
+ if (post.deleted) {
+ pull(postsInfo.idList, post.id);
+ await dataStorage.removeItem(
+ this.getPostKey(timeline.uniqueId, post.id)
+ );
+ await dataStorage.removeItem(
+ this.getPostDataKey(timeline.uniqueId, post.id)
+ );
+ } else {
+ await dataStorage.setItem<HttpTimelinePostInfo>(
+ this.getPostKey(timeline.uniqueId, post.id),
+ post
+ );
+ const data = dataList[i];
+ if (data != null) {
+ await dataStorage.setItem<BlobWithEtag>(
+ this.getPostDataKey(timeline.uniqueId, post.id),
+ data
+ );
+ }
+ newPosts.push(post);
+ newPostDataList.push(data);
+ }
+ }
- for (const post of httpPosts) {
- if (post.deleted) {
- pull(postListInfo.idList, post.id);
- await dataStorage.removeItem(
- this.getPostKey(timeline.uniqueId, post.id)
+ const oldIdList = postsInfo.idList;
+
+ postsInfo.idList = [...oldIdList, ...newPosts.map((post) => post.id)];
+ postsInfo.lastUpdated = now.toISOString();
+ await dataStorage.setItem<PostsInfoCache>(postsInfoKey, postsInfo);
+
+ const posts: TimelinePostInfo[] = [
+ ...(await convertPostList(
+ await Promise.all(
+ oldIdList.map((postId) =>
+ dataStorage.getItem<HttpTimelinePostInfo>(
+ this.getPostKey(timeline.uniqueId, postId)
+ )
+ )
+ ),
+ (post) =>
+ dataStorage
+ .getItem<BlobWithEtag | null>(
+ this.getPostDataKey(timeline.uniqueId, post.id)
+ )
+ .then((d) => d?.data)
+ )),
+ ...(await convertPostList(newPosts, (post, i) =>
+ Promise.resolve(newPostDataList[i]?.data)
+ )),
+ ];
+ return { posts, type: 'synced' };
+ } catch (e) {
+ if (e instanceof HttpNetworkError) {
+ const httpPosts = await Promise.all(
+ postsInfo.idList.map((postId) =>
+ dataStorage.getItem<HttpTimelinePostInfo>(
+ this.getPostKey(timeline.uniqueId, postId)
+ )
+ )
);
- await dataStorage.removeItem(
- this.getPostDataKey(timeline.uniqueId, post.id)
+
+ const posts = await convertPostList(httpPosts, (post) =>
+ dataStorage
+ .getItem<BlobWithEtag | null>(
+ this.getPostDataKey(timeline.uniqueId, post.id)
+ )
+ .then((d) => d?.data)
);
+
+ return { posts, type: 'cache' };
} else {
- await dataStorage.setItem<HttpTimelinePostInfo>(
- this.getPostKey(timeline.uniqueId, post.id),
- post
- );
- newPosts.push(post);
+ throw e;
}
}
-
- const oldIdList = postListInfo.idList;
-
- postListInfo.idList = [...oldIdList, ...newPosts.map((post) => post.id)];
- postListInfo.lastUpdated = now.toISOString();
- await dataStorage.setItem<PostListInfo>(postListInfoKey, postListInfo);
-
- posts = [
- ...(await Promise.all(
- oldIdList.map((postId) =>
- dataStorage.getItem<HttpTimelinePostInfo>(
- this.getPostKey(timeline.uniqueId, postId)
- )
- )
- )),
- ...newPosts,
- ].map((post) => ({ ...post, timelineName }));
}
-
- this._postListSubscriptionHub.update(timelineName, () =>
- Promise.resolve({
- state: 'synced',
- posts,
- })
- );
-
- return posts;
}
- private _postListSubscriptionHub = new SubscriptionHub<
+ private _postsSubscriptionHub = new SubscriptionHub<
string,
- TimelinePostListState
- >(
- (key) => key,
- () => ({
- state: 'loading',
- posts: [],
- }),
- async (key) => {
- const state: TimelinePostListState = {
- state: 'syncing',
- posts: await this.getCachedPostList(key),
+ TimelinePostsWithSyncState
+ >({
+ setup: (key, next) => {
+ const sub = this.timelineHub.subscribe(key, (timelineState) => {
+ if (timelineState.timeline == null) {
+ if (timelineState.syncState === 'offline') {
+ next({ state: 'offline', posts: [] });
+ } else {
+ next({ state: 'synced', posts: [] });
+ }
+ } else {
+ void this.fetchAndCachePosts(timelineState.timeline).then(
+ (result) => {
+ if (result === 'forbid') {
+ next({ state: 'forbid', posts: [] });
+ } else if (result === 'offline') {
+ next({ state: 'offline', posts: [] });
+ } else if (result.type === 'synced') {
+ next({ state: 'synced', posts: result.posts });
+ } else {
+ next({ state: 'offline', posts: result.posts });
+ }
+ }
+ );
+ }
+ });
+ return () => {
+ sub.unsubscribe();
};
- void this.syncPostList(key);
- return state;
- }
- );
-
- get postListHub(): ISubscriptionHub<string, TimelinePostListState> {
- return this._postListSubscriptionHub;
- }
-
- private async getCachePostData(
- timelineName: string,
- postId: number
- ): Promise<Blob | null> {
- const timeline = await this.getTimeline(timelineName).toPromise();
- const cache = await dataStorage.getItem<BlobWithEtag | null>(
- this.getPostDataKey(timeline.uniqueId, postId)
- );
- if (cache == null) {
- return null;
- } else {
- return cache.data;
- }
- }
-
- private async syncCachePostData(
- timelineName: string,
- postId: number
- ): Promise<Blob | null> {
- const timeline = await this.getTimeline(timelineName).toPromise();
- const dataKey = this.getPostDataKey(timeline.uniqueId, postId);
- const cache = await dataStorage.getItem<BlobWithEtag | null>(dataKey);
-
- if (cache == null) {
- const dataWithEtag = await getHttpTimelineClient().getPostData(
- timelineName,
- postId,
- userService.currentUser?.token
- );
- await dataStorage.setItem<BlobWithEtag>(dataKey, dataWithEtag);
- this._postDataSubscriptionHub.update(
- {
- postId,
- timelineName,
- },
- () =>
- Promise.resolve({
- blob: dataWithEtag.data,
- url: URL.createObjectURL(dataWithEtag.data),
- })
- );
- return dataWithEtag.data;
- } else {
- const res = await getHttpTimelineClient().getPostData(
- timelineName,
- postId,
- userService.currentUser?.token,
- cache.etag
- );
- if (res instanceof NotModified) {
- return cache.data;
- } else {
- await dataStorage.setItem<BlobWithEtag>(dataKey, res);
- this._postDataSubscriptionHub.update(
- {
- postId,
- timelineName,
- },
- () =>
- Promise.resolve({
- blob: res.data,
- url: URL.createObjectURL(res.data),
- })
- );
- return res.data;
- }
- }
- }
-
- private _postDataSubscriptionHub = new SubscriptionHub<
- PostKey,
- BlobWithUrl | null
- >(
- (key) => `${key.timelineName}/${key.postId}`,
- () => null,
- async (key) => {
- const blob = await this.getCachePostData(key.timelineName, key.postId);
- const result =
- blob == null
- ? null
- : {
- blob,
- url: URL.createObjectURL(blob),
- };
- void this.syncCachePostData(key.timelineName, key.postId);
- return result;
},
- (_key, data) => {
- if (data != null) URL.revokeObjectURL(data.url);
- }
- );
+ });
- get postDataHub(): ISubscriptionHub<PostKey, BlobWithUrl | null> {
- return this._postDataSubscriptionHub;
+ get postsHub(): ISubscriptionHub<string, TimelinePostsWithSyncState> {
+ return this._postsSubscriptionHub;
}
createPost(
@@ -576,14 +567,24 @@ export class TimelineService {
return from(
getHttpTimelineClient()
.postPost(timelineName, request, user.token)
- .then((res) => {
- this._postListSubscriptionHub.update(timelineName, (_, old) => {
- return Promise.resolve({
+ .then((post) =>
+ this.convertPost(post, () =>
+ Promise.resolve(
+ (request.content as TimelineCreatePostImageContent).data
+ )
+ )
+ )
+ .then((post) => {
+ this._postsSubscriptionHub.updateWithOld(timelineName, (old) => {
+ if (old instanceof NoValue) {
+ throw new Error('Posts has not been loaded.');
+ }
+ return {
...old,
- posts: [...old.posts, { ...res, timelineName }],
- });
+ posts: [...old.posts, post],
+ };
});
- return res;
+ return post;
})
).pipe(map((post) => ({ ...post, timelineName })));
}
@@ -594,11 +595,14 @@ export class TimelineService {
getHttpTimelineClient()
.deletePost(timelineName, postId, user.token)
.then(() => {
- this._postListSubscriptionHub.update(timelineName, (_, old) => {
- return Promise.resolve({
+ this._postsSubscriptionHub.updateWithOld(timelineName, (old) => {
+ if (old instanceof NoValue) {
+ throw new Error('Posts has not been loaded.');
+ }
+ return {
...old,
posts: old.posts.filter((post) => post.id != postId),
- });
+ };
});
})
);
@@ -672,19 +676,14 @@ export function validateTimelineName(name: string): boolean {
return timelineNameReg.test(name);
}
-export function usePostList(
- timelineName: string | null | undefined
-): TimelinePostListState | undefined {
- const [state, setState] = React.useState<TimelinePostListState | undefined>(
+export function useTimelineInfo(
+ timelineName: string
+): TimelineWithSyncState | undefined {
+ const [state, setState] = React.useState<TimelineWithSyncState | undefined>(
undefined
);
React.useEffect(() => {
- if (timelineName == null) {
- setState(undefined);
- return;
- }
-
- const subscription = timelineService.postListHub.subscribe(
+ const subscription = timelineService.timelineHub.subscribe(
timelineName,
(data) => {
setState(data);
@@ -697,30 +696,27 @@ export function usePostList(
return state;
}
-export function usePostDataUrl(
- enable: boolean,
- timelineName: string,
- postId: number
-): string | undefined {
- const [url, setUrl] = React.useState<string | undefined>(undefined);
+export function usePostList(
+ timelineName: string | null | undefined
+): TimelinePostsWithSyncState | undefined {
+ const [state, setState] = React.useState<
+ TimelinePostsWithSyncState | undefined
+ >(undefined);
React.useEffect(() => {
- if (!enable) {
- setUrl(undefined);
+ if (timelineName == null) {
+ setState(undefined);
return;
}
- const subscription = timelineService.postDataHub.subscribe(
- {
- timelineName,
- postId,
- },
+ const subscription = timelineService.postsHub.subscribe(
+ timelineName,
(data) => {
- setUrl(data?.url);
+ setState(data);
}
);
return () => {
subscription.unsubscribe();
};
- }, [timelineName, postId, enable]);
- return url;
+ }, [timelineName]);
+ return state;
}
diff --git a/Timeline/ClientApp/src/app/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts
index dec9929f..7d522b26 100644
--- a/Timeline/ClientApp/src/app/data/user.ts
+++ b/Timeline/ClientApp/src/app/data/user.ts
@@ -19,8 +19,6 @@ import {
HttpUser,
} from '../http/user';
-import { BlobWithUrl } from './common';
-
export type User = HttpUser;
export interface UserAuthInfo {
@@ -230,27 +228,16 @@ export function checkLogin(): UserWithToken {
export class UserNotExistError extends Error {}
-export type AvatarInfo = BlobWithUrl;
-
export class UserInfoService {
- private _avatarSubscriptionHub = new SubscriptionHub<
- string,
- AvatarInfo | null
- >(
- (key) => key,
- () => null,
- async (key) => {
- const blob = (await getHttpUserClient().getAvatar(key)).data;
- const url = URL.createObjectURL(blob);
- return {
- blob,
- url,
- };
+ private _avatarSubscriptionHub = new SubscriptionHub<string, Blob>({
+ setup: (key, next) => {
+ void getHttpUserClient()
+ .getAvatar(key)
+ .then((res) => {
+ next(res.data);
+ });
},
- (_key, data) => {
- if (data != null) URL.revokeObjectURL(data.url);
- }
- );
+ });
getUserInfo(username: string): Observable<User> {
return from(getHttpUserClient().get(username)).pipe(
@@ -261,40 +248,33 @@ export class UserInfoService {
async setAvatar(username: string, blob: Blob): Promise<void> {
const user = checkLogin();
await getHttpUserClient().putAvatar(username, blob, user.token);
- this._avatarSubscriptionHub.update(username, () =>
- Promise.resolve({
- blob,
- url: URL.createObjectURL(blob),
- })
- );
+ this._avatarSubscriptionHub.update(username, blob);
}
- get avatarHub(): ISubscriptionHub<string, AvatarInfo | null> {
+ get avatarHub(): ISubscriptionHub<string, Blob> {
return this._avatarSubscriptionHub;
}
}
export const userInfoService = new UserInfoService();
-export function useAvatarUrl(username?: string): string | undefined {
- const [avatarUrl, setAvatarUrl] = React.useState<string | undefined>(
- undefined
- );
+export function useAvatar(username?: string): Blob | undefined {
+ const [state, setState] = React.useState<Blob | undefined>(undefined);
React.useEffect(() => {
if (username == null) {
- setAvatarUrl(undefined);
+ setState(undefined);
return;
}
const subscription = userInfoService.avatarHub.subscribe(
username,
- (info) => {
- setAvatarUrl(info?.url);
+ (blob) => {
+ setState(blob);
}
);
return () => {
subscription.unsubscribe();
};
}, [username]);
- return avatarUrl;
+ return state;
}
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
index c25b2376..ece7d38a 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineInfoCard.tsx
@@ -10,10 +10,11 @@ import {
import { useTranslation } from 'react-i18next';
import { fromEvent } from 'rxjs';
-import { useAvatarUrl } from '../data/user';
+import { useAvatar } from '../data/user';
import { timelineVisibilityTooltipTranslationMap } from '../data/timeline';
import { TimelineCardComponentProps } from './TimelinePageTemplateUI';
+import BlobImage from '../common/BlobImage';
export type OrdinaryTimelineManageItem = 'delete';
@@ -26,7 +27,7 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
const { t } = useTranslation();
- const avatarUrl = useAvatarUrl(props.timeline.owner.username);
+ const avatar = useAvatar(props.timeline.owner.username);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const containerRef = React.useRef<HTMLDivElement>(null!);
@@ -60,8 +61,8 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
{props.timeline.name}
</h3>
<div className="d-inline-block align-middle">
- <img
- src={avatarUrl}
+ <BlobImage
+ blob={avatar}
onLoad={notifyHeight}
className="avatar small rounded-circle"
/>
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
index 11ac9f08..727de1fe 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineItem.tsx
@@ -16,8 +16,10 @@ import Svg from 'react-inlinesvg';
import chevronDownIcon from 'bootstrap-icons/icons/chevron-down.svg';
import trashIcon from 'bootstrap-icons/icons/trash.svg';
-import { useAvatarUrl } from '../data/user';
-import { TimelinePostInfo, usePostDataUrl } from '../data/timeline';
+import BlobImage from '../common/BlobImage';
+
+import { useAvatar } from '../data/user';
+import { TimelinePostInfo } from '../data/timeline';
const TimelinePostDeleteConfirmDialog: React.FC<{
toggle: () => void;
@@ -70,13 +72,7 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
const { more, onResize } = props;
- const avatarUrl = useAvatarUrl(props.post.author.username);
-
- const dataUrl = usePostDataUrl(
- props.post.content.type === 'image',
- props.post.timelineName,
- props.post.id
- );
+ const avatar = useAvatar(props.post.author.username);
const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false);
const toggleDeleteDialog = React.useCallback(
@@ -132,7 +128,11 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
className="float-right float-sm-left mx-2"
to={'/users/' + props.post.author.username}
>
- <img onLoad={onResize} src={avatarUrl} className="avatar rounded" />
+ <BlobImage
+ onLoad={onResize}
+ blob={avatar}
+ className="avatar rounded"
+ />
</Link>
{(() => {
const { content } = props.post;
@@ -140,9 +140,9 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
return content.text;
} else {
return (
- <img
+ <BlobImage
onLoad={onResize}
- src={dataUrl}
+ blob={content.data}
className="timeline-content-image"
/>
);
diff --git a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
index 8c637f46..39af412e 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelineMember.tsx
@@ -10,9 +10,10 @@ import {
Button,
} from 'reactstrap';
-import { User, useAvatarUrl } from '../data/user';
+import { User, useAvatar } from '../data/user';
import SearchInput from '../common/SearchInput';
+import BlobImage from '../common/BlobImage';
const TimelineMemberItem: React.FC<{
user: User;
@@ -21,13 +22,13 @@ const TimelineMemberItem: React.FC<{
}> = ({ user, owner, onRemove }) => {
const { t } = useTranslation();
- const avatarUrl = useAvatarUrl(user.username);
+ const avatar = useAvatar(user.username);
return (
<ListGroupItem className="container">
<Row>
<Col className="col-auto">
- <img src={avatarUrl} className="avatar small" />
+ <BlobImage blob={avatar} className="avatar small" />
</Col>
<Col>
<Row>{user.nickname}</Row>
@@ -84,7 +85,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
| { type: 'init' }
>({ type: 'init' });
- const userSearchAvatarUrl = useAvatarUrl(
+ const userSearchAvatar = useAvatar(
userSearchState.type === 'user' ? userSearchState.data.username : undefined
);
@@ -156,8 +157,8 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
<Container className="mb-3">
<Row>
<Col className="col-auto">
- <img
- src={userSearchAvatarUrl}
+ <BlobImage
+ blob={userSearchAvatar}
className="avatar small"
/>
</Col>
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
index a68d08c6..afa9464a 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
@@ -1,8 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { concat, without } from 'lodash';
import { of } from 'rxjs';
-import { catchError, map } from 'rxjs/operators';
+import { catchError } from 'rxjs/operators';
import { ExcludeKey } from '../utilities/type';
import { pushAlert } from '../common/alert-service';
@@ -10,8 +9,8 @@ import { useUser, userInfoService, UserNotExistError } from '../data/user';
import {
timelineService,
TimelineInfo,
- TimelineNotExistError,
usePostList,
+ useTimelineInfo,
} from '../data/timeline';
import { TimelineDeleteCallback } from './Timeline';
@@ -51,34 +50,22 @@ export default function TimelinePageTemplate<
const [dialog, setDialog] = React.useState<null | 'property' | 'member'>(
null
);
- const [timeline, setTimeline] = React.useState<TimelineInfo | undefined>(
- undefined
- );
- const postListState = usePostList(timeline?.name);
-
- const [error, setError] = React.useState<string | undefined>(undefined);
-
- React.useEffect(() => {
- const subscription = service.getTimeline(name).subscribe(
- (ti) => {
- setTimeline(ti);
- },
- (error) => {
- if (error instanceof TimelineNotExistError) {
- setError(t(props.notFoundI18nKey));
- } else {
- setError(
- // TODO: Convert this to a function.
- (error as { message?: string })?.message ?? 'Unknown error'
- );
- }
- }
- );
- return () => {
- subscription.unsubscribe();
- };
- }, [name, service, user, t, props.dataVersion, props.notFoundI18nKey]);
+ const timelineState = useTimelineInfo(name);
+
+ const timeline = timelineState?.timeline;
+
+ const postListState = usePostList(name);
+
+ const error: string | undefined = (() => {
+ if (timelineState != null) {
+ const { syncState, timeline } = timelineState;
+ if (syncState === 'offline' && timeline == null) return 'Network Error';
+ if (syncState !== 'offline' && timeline == null)
+ return t(props.notFoundI18nKey);
+ }
+ return undefined;
+ })();
const closeDialog = React.useCallback((): void => {
setDialog(null);
@@ -102,14 +89,7 @@ export default function TimelinePageTemplate<
description: timeline.description,
}}
onProcess={(req) => {
- return service
- .changeTimelineProperty(name, req)
- .pipe(
- map((newTimeline) => {
- setTimeline(newTimeline);
- })
- )
- .toPromise();
+ return service.changeTimelineProperty(name, req).toPromise().then();
}}
/>
);
@@ -143,33 +123,10 @@ export default function TimelinePageTemplate<
.toPromise();
},
onAddUser: (u) => {
- return service
- .addMember(name, u.username)
- .pipe(
- map(() => {
- setTimeline({
- ...timeline,
- members: concat(timeline.members, u),
- });
- })
- )
- .toPromise();
+ return service.addMember(name, u.username).toPromise().then();
},
onRemoveUser: (u) => {
- service.removeMember(name, u).subscribe(() => {
- const toDelete = timeline.members.find(
- (m) => m.username === u
- );
- if (toDelete == null) {
- throw new UiLogicError(
- 'The member to delete is not in list.'
- );
- }
- setTimeline({
- ...timeline,
- members: without(timeline.members, toDelete),
- });
- });
+ service.removeMember(name, u);
},
}
: null
@@ -220,7 +177,7 @@ export default function TimelinePageTemplate<
<>
<UiComponent
error={error}
- timeline={timeline}
+ timeline={timeline ?? undefined}
postListState={postListState}
onDelete={onDelete}
onPost={
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
index dc5bfda8..42171e13 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
@@ -12,7 +12,7 @@ import { getAlertHost } from '../common/alert-service';
import { useEventEmiiter, UiLogicError } from '../common';
import {
TimelineInfo,
- TimelinePostListState,
+ TimelinePostsWithSyncState,
timelineService,
} from '../data/timeline';
import { userService } from '../data/user';
@@ -24,8 +24,10 @@ import Timeline, {
import AppBar from '../common/AppBar';
import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit';
+type TimelinePostSyncState = 'syncing' | 'synced' | 'offline';
+
const TimelinePostSyncStateBadge: React.FC<{
- state: 'syncing' | 'synced' | 'offline';
+ state: TimelinePostSyncState;
style?: CSSProperties;
className?: string;
}> = ({ state, style, className }) => {
@@ -84,7 +86,7 @@ export interface TimelineCardComponentProps<TManageItems> {
export interface TimelinePageTemplateUIProps<TManageItems> {
avatarKey?: string | number;
timeline?: TimelineInfo;
- postListState?: TimelinePostListState;
+ postListState?: TimelinePostsWithSyncState;
CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>;
onMember: () => void;
onManage?: (item: TManageItems | 'property') => void;
@@ -197,7 +199,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
} else {
if (timeline != null) {
let timelineBody: React.ReactElement;
- if (postListState != null && postListState.state !== 'loading') {
+ if (postListState != null) {
if (postListState.state === 'forbid') {
timelineBody = (
<p className="text-danger">{t('timeline.messageCantSee')}</p>
diff --git a/Timeline/ClientApp/src/app/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
index 8b1294c4..0f321f32 100644
--- a/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
+++ b/Timeline/ClientApp/src/app/user/UserInfoCard.tsx
@@ -11,9 +11,10 @@ import { useTranslation } from 'react-i18next';
import { fromEvent } from 'rxjs';
import { timelineVisibilityTooltipTranslationMap } from '../data/timeline';
-import { useAvatarUrl } from '../data/user';
+import { useAvatar } from '../data/user';
import { TimelineCardComponentProps } from '../timeline/TimelinePageTemplateUI';
+import BlobImage from '../common/BlobImage';
export type PersonalTimelineManageItem = 'avatar' | 'nickname';
@@ -25,7 +26,7 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
const { onHeight, onManage } = props;
const { t } = useTranslation();
- const avatarUrl = useAvatarUrl(props.timeline.owner.username);
+ const avatar = useAvatar(props.timeline.owner.username);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const containerRef = React.useRef<HTMLDivElement>(null!);
@@ -55,8 +56,8 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
className={clsx('rounded border bg-light p-2', props.className)}
onTransitionEnd={notifyHeight}
>
- <img
- src={avatarUrl}
+ <BlobImage
+ blob={avatar}
onLoad={notifyHeight}
className="avatar large mr-2 mb-2 rounded-circle float-left"
/>