fix: replace wasm with axios (#5856)

* fix: replace wasm with axios

* fix: login redirect

* fix: flag emoji on windows
This commit is contained in:
Kilu.He 2024-08-02 12:19:32 +08:00 committed by GitHub
parent 04556252e1
commit cb60488bbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 733 additions and 358 deletions

View File

@ -54,10 +54,7 @@ http {
root /usr/share/nginx/html;
expires 30d;
access_log off;
location ~* \.wasm$ {
types { application/wasm wasm; }
default_type application/wasm;
}
}
location /appflowy.svg {

View File

@ -68,14 +68,25 @@ const createServer = async (req: Request) => {
logger.info(`Request URL: ${hostname}${reqUrl.pathname}`);
if (reqUrl.pathname === '/after-payment') {
if (['/after-payment', '/login'].includes(reqUrl.pathname)) {
timer();
const htmlData = fs.readFileSync(indexPath, 'utf8');
const $ = load(htmlData);
$('title').text('Payment Success | AppFlowy');
$('link[rel="icon"]').attr('href', '/appflowy.svg');
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', 'Payment success on AppFlowy');
let title, description;
if (reqUrl.pathname === '/after-payment') {
title = 'Payment Success | AppFlowy';
description = 'Payment success on AppFlowy';
}
if (reqUrl.pathname === '/login') {
title = 'Login | AppFlowy';
description = 'Login to AppFlowy';
}
if (title) $('title').text(title);
if (description) setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
return new Response($.html(), {
headers: { 'Content-Type': 'text/html' },

View File

@ -24,7 +24,6 @@
"coverage": "pnpm run test:unit && pnpm run test:components"
},
"dependencies": {
"@appflowyinc/client-api-wasm": "0.1.4",
"@atlaskit/primitives": "^5.5.3",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",

View File

@ -5,9 +5,6 @@ settings:
excludeLinksFromLockfile: false
dependencies:
'@appflowyinc/client-api-wasm':
specifier: 0.1.4
version: 0.1.4
'@atlaskit/primitives':
specifier: ^5.5.3
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
@ -454,10 +451,6 @@ packages:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
/@appflowyinc/client-api-wasm@0.1.4:
resolution: {integrity: sha512-3uBpy3n+aIG0fapPAroMfL8JLdAPtqPAkpV+LOxlRnMW4Au2JQcW8TW0P3K1YAe16tDZ62ZIZPoG6Bi40RDRoQ==}
dev: false
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
resolution: {integrity: sha512-iO6+hIp09dF4iAZQarVz3vKY1kM5Ij5CExYcK9jgc2q+OH8nv8n+BPFeJTdzGOGopmbUZn5Opj9pYQvge1Gr4Q==}
peerDependencies:

View File

@ -1,8 +1,8 @@
import { expect } from '@jest/globals';
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '../fetch';
import { APIService } from '@/application/services/js-services/wasm';
import { APIService } from '@/application/services/js-services/http';
jest.mock('@/application/services/js-services/wasm', () => {
jest.mock('@/application/services/js-services/http', () => {
return {
APIService: {
getPublishView: jest.fn(),

View File

@ -5,7 +5,7 @@ import { fetchViewInfo } from '@/application/services/js-services/fetch';
import { expect, jest } from '@jest/globals';
import { getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache';
jest.mock('@/application/services/js-services/wasm/client_api', () => {
jest.mock('@/application/services/js-services/http/http_api', () => {
return {
initAPIService: jest.fn(),
};

View File

@ -114,7 +114,7 @@ export async function getPublishViewMeta<
export async function getPublishView<
T extends {
data: number[];
data: Uint8Array;
rows?: Record<RowId, number[]>;
visibleViewIds?: ViewId[];
relations?: Record<DatabaseId, ViewId>;
@ -219,7 +219,7 @@ export async function revalidatePublishViewMeta<
export async function revalidatePublishView<
T extends {
data: number[];
data: Uint8Array;
rows?: Record<RowId, number[]>;
visibleViewIds?: ViewId[];
relations?: Record<DatabaseId, ViewId>;
@ -260,10 +260,9 @@ export async function revalidatePublishView<
}
}
console.log('====', rows);
const state = new Uint8Array(data);
console.log('====', data);
applyYDoc(collab, state);
applyYDoc(collab, data);
}
export async function deleteViewMeta(name: string) {

View File

@ -1,4 +1,4 @@
import { APIService } from '@/application/services/js-services/wasm';
import { APIService } from '@/application/services/js-services/http';
const pendingRequests = new Map();

View File

@ -0,0 +1,94 @@
import { refreshToken as refreshSessionToken } from '@/application/session/token';
import axios, { AxiosInstance } from 'axios';
let axiosInstance: AxiosInstance | null = null;
export function initGrantService(baseURL: string) {
if (axiosInstance) {
return;
}
axiosInstance = axios.create({
baseURL,
});
axiosInstance.interceptors.request.use((config) => {
Object.assign(config.headers, {
'Content-Type': 'application/json',
});
return config;
});
}
export async function refreshToken(refresh_token: string) {
const response = await axiosInstance?.post<{
access_token: string;
expires_at: number;
refresh_token: string;
}>('/token?grant_type=refresh_token', {
refresh_token,
});
const newToken = response?.data;
if (newToken) {
refreshSessionToken(JSON.stringify(newToken));
}
return newToken;
}
export async function signInWithMagicLink(email: string, authUrl: string) {
const res = await axiosInstance?.post(
'/magiclink',
{
code_challenge: '',
code_challenge_method: '',
data: {},
email,
},
{
headers: {
Redirect_to: authUrl,
},
}
);
return res?.data;
}
export async function settings() {
const res = await axiosInstance?.get('/settings');
return res?.data;
}
export function signInGoogle(authUrl: string) {
const provider = 'google';
const redirectTo = encodeURIComponent(authUrl);
const accessType = 'offline';
const prompt = 'consent';
const baseURL = axiosInstance?.defaults.baseURL;
const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}&access_type=${accessType}&prompt=${prompt}`;
window.open(url, '_current');
}
export function signInGithub(authUrl: string) {
const provider = 'github';
const redirectTo = encodeURIComponent(authUrl);
const baseURL = axiosInstance?.defaults.baseURL;
const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`;
window.open(url, '_current');
}
export function signInDiscord(authUrl: string) {
const provider = 'discord';
const redirectTo = encodeURIComponent(authUrl);
const baseURL = axiosInstance?.defaults.baseURL;
const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`;
window.open(url, '_current');
}

View File

@ -0,0 +1,489 @@
import { DatabaseId, RowId, ViewId, ViewLayout } from '@/application/collab.type';
import { GlobalComment, Reaction } from '@/application/comment.type';
import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue';
import { blobToBytes } from '@/application/services/js-services/http/utils';
import { AFCloudConfig } from '@/application/services/services.type';
import { getTokenParsed, invalidToken } from '@/application/session/token';
import { FolderView, User, Workspace } from '@/application/types';
import axios, { AxiosInstance } from 'axios';
import dayjs from 'dayjs';
export * from './gotrue';
let axiosInstance: AxiosInstance | null = null;
export function initAPIService(config: AFCloudConfig) {
if (axiosInstance) {
return;
}
axiosInstance = axios.create({
baseURL: config.baseURL,
});
initGrantService(config.gotrueURL);
axiosInstance.interceptors.request.use(
async (config) => {
const token = getTokenParsed();
Object.assign(config.headers, {
'Content-Type': 'application/json',
});
if (!token) {
return config;
}
const isExpired = dayjs().isAfter(dayjs.unix(token.expires_at));
let access_token = token.access_token;
const refresh_token = token.refresh_token;
if (isExpired) {
const newToken = await refreshToken(refresh_token);
access_token = newToken?.access_token || '';
}
if (access_token) {
Object.assign(config.headers, {
Authorization: `Bearer ${access_token}`,
});
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
axiosInstance.interceptors.response.use(async (response) => {
const status = response.status;
if (status === 401) {
const token = getTokenParsed();
if (!token) {
invalidToken();
return response;
}
const refresh_token = token.refresh_token;
try {
await refreshToken(refresh_token);
} catch (e) {
invalidToken();
}
}
return response;
});
}
export async function signInWithUrl(url: string) {
const hash = new URL(url).hash;
if (!hash) {
return Promise.reject('No hash found');
}
const params = new URLSearchParams(hash.slice(1));
const refresh_token = params.get('refresh_token');
if (!refresh_token) {
return Promise.reject('No access_token found');
}
await refreshToken(refresh_token);
}
export async function verifyToken(accessToken: string) {
const url = `/api/user/verify/${accessToken}`;
const response = await axiosInstance?.get<{
code: number;
data?: {
is_new: boolean;
};
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
return data.data;
}
return Promise.reject(data);
}
export async function getCurrentUser(): Promise<User> {
const url = '/api/user/profile';
const response = await axiosInstance?.get<{
code: number;
data?: {
uid: number;
uuid: string;
email: string;
name: string;
metadata: {
icon_url: string;
};
encryption_sign: null;
latest_workspace_id: string;
updated_at: number;
};
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
const { uid, uuid, email, name, metadata } = data.data;
return {
uid: String(uid),
uuid,
email,
name,
avatar: metadata.icon_url,
};
}
return Promise.reject(data);
}
export async function getPublishViewMeta(namespace: string, publishName: string) {
const url = `/api/workspace/published/${namespace}/${publishName}`;
const response = await axiosInstance?.get(url);
return response?.data;
}
export async function getPublishViewBlob(namespace: string, publishName: string) {
const url = `/api/workspace/published/${namespace}/${publishName}/blob`;
const response = await axiosInstance?.get(url, {
responseType: 'blob',
});
return blobToBytes(response?.data);
}
export async function getPublishView(publishNamespace: string, publishName: string) {
const meta = await getPublishViewMeta(publishNamespace, publishName);
const blob = await getPublishViewBlob(publishNamespace, publishName);
if (meta.view.layout === ViewLayout.Document) {
return {
data: blob,
meta,
};
}
try {
const decoder = new TextDecoder('utf-8');
const jsonStr = decoder.decode(blob);
const res = JSON.parse(jsonStr) as {
database_collab: Uint8Array;
database_row_collabs: Record<RowId, number[]>;
database_row_document_collabs: Record<string, number[]>;
visible_database_view_ids: ViewId[];
database_relations: Record<DatabaseId, ViewId>;
};
return {
data: new Uint8Array(res.database_collab),
rows: res.database_row_collabs,
visibleViewIds: res.visible_database_view_ids,
relations: res.database_relations,
meta,
};
} catch (e) {
return Promise.reject(e);
}
}
export async function getPublishInfoWithViewId(viewId: string) {
const url = `/api/workspace/published-info/${viewId}`;
const response = await axiosInstance?.get<{
code: number;
data?: {
namespace: string;
publish_name: string;
};
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
return data.data;
}
return Promise.reject(data);
}
export async function getPublishViewComments(viewId: string): Promise<GlobalComment[]> {
const url = `/api/workspace/published-info/${viewId}/comment`;
const response = await axiosInstance?.get<{
code: number;
data?: {
comments: {
comment_id: string;
user: {
uuid: string;
name: string;
avatar_url: string | null;
};
content: string;
created_at: string;
last_updated_at: string;
reply_comment_id: string | null;
is_deleted: boolean;
can_be_deleted: boolean;
}[];
};
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
const { comments } = data.data;
return comments.map((comment) => {
return {
commentId: comment.comment_id,
user: {
uuid: comment.user?.uuid || '',
name: comment.user?.name || '',
avatarUrl: comment.user?.avatar_url || null,
},
content: comment.content,
createdAt: comment.created_at,
lastUpdatedAt: comment.last_updated_at,
replyCommentId: comment.reply_comment_id,
isDeleted: comment.is_deleted,
canDeleted: comment.can_be_deleted,
};
});
}
return Promise.reject(data);
}
export async function getReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
let url = `/api/workspace/published-info/${viewId}/reaction`;
if (commentId) {
url += `?comment_id=${commentId}`;
}
const response = await axiosInstance?.get<{
code: number;
data?: {
reactions: {
reaction_type: string;
react_users: {
uuid: string;
name: string;
avatar_url: string | null;
}[];
comment_id: string;
}[];
};
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
const { reactions } = data.data;
const reactionsMap: Record<string, Reaction[]> = {};
for (const reaction of reactions) {
if (!reactionsMap[reaction.comment_id]) {
reactionsMap[reaction.comment_id] = [];
}
reactionsMap[reaction.comment_id].push({
reactionType: reaction.reaction_type,
commentId: reaction.comment_id,
reactUsers: reaction.react_users.map((user) => ({
uuid: user.uuid,
name: user.name,
avatarUrl: user.avatar_url,
})),
});
}
return reactionsMap;
}
return Promise.reject(data);
}
export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) {
const url = `/api/workspace/published-info/${viewId}/comment`;
const response = await axiosInstance?.post<{ code: number; message: string }>(url, {
content,
reply_comment_id: replyCommentId,
});
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) {
const url = `/api/workspace/published-info/${viewId}/comment`;
const response = await axiosInstance?.delete<{ code: number; message: string }>(url, {
data: {
comment_id: commentId,
},
});
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function addReaction(viewId: string, commentId: string, reactionType: string) {
const url = `/api/workspace/published-info/${viewId}/reaction`;
const response = await axiosInstance?.post<{ code: number; message: string }>(url, {
comment_id: commentId,
reaction_type: reactionType,
});
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function removeReaction(viewId: string, commentId: string, reactionType: string) {
const url = `/api/workspace/published-info/${viewId}/reaction`;
const response = await axiosInstance?.delete<{ code: number; message: string }>(url, {
data: {
comment_id: commentId,
reaction_type: reactionType,
},
});
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function getWorkspaces(): Promise<Workspace[]> {
const query = new URLSearchParams({
include_member_count: 'true',
});
const url = `/api/workspace?${query.toString()}`;
const response = await axiosInstance?.get<{
code: number;
data?: {
workspace_id: string;
workspace_name: string;
member_count: number;
icon: string;
}[];
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
return data.data.map((workspace) => {
return {
id: workspace.workspace_id,
name: workspace.workspace_name,
memberCount: workspace.member_count,
icon: workspace.icon,
};
});
}
return Promise.reject(data);
}
export interface WorkspaceFolder {
view_id: string;
icon: string | null;
name: string;
is_space: boolean;
is_private: boolean;
extra: {
is_space: boolean;
space_created_at: number;
space_icon: string;
space_icon_color: string;
space_permission: number;
};
children: WorkspaceFolder[];
}
function iterateFolder(folder: WorkspaceFolder): FolderView {
return {
id: folder.view_id,
name: folder.name,
icon: folder.icon,
isSpace: folder.is_space,
extra: folder.extra ? JSON.stringify(folder.extra) : null,
isPrivate: folder.is_private,
children: folder.children.map((child: WorkspaceFolder) => {
return iterateFolder(child);
}),
};
}
export async function getWorkspaceFolder(workspaceId: string): Promise<FolderView> {
const url = `/api/workspace/${workspaceId}/folder`;
const response = await axiosInstance?.get<{
code: number;
data?: WorkspaceFolder;
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
return iterateFolder(data.data);
}
return Promise.reject(data);
}
export interface DuplicatePublishViewPayload {
published_collab_type: 0 | 1 | 2 | 3 | 4 | 5 | 6;
published_view_id: string;
dest_view_id: string;
}
export async function duplicatePublishView(workspaceId: string, payload: DuplicatePublishViewPayload) {
const url = `/api/workspace/${workspaceId}/published-duplicate`;
const res = await axiosInstance?.post<{
code: number;
message: string;
}>(url, payload);
if (res?.data.code === 0) {
return;
}
return Promise.reject(res?.data.message);
}

View File

@ -0,0 +1 @@
export * as APIService from './http_api';

View File

@ -0,0 +1,17 @@
export function blobToBytes(blob: Blob): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (!(reader.result instanceof ArrayBuffer)) {
reject(new Error('Failed to convert blob to bytes'));
return;
}
resolve(new Uint8Array(reader.result));
};
reader.onerror = reject;
reader.readAsArrayBuffer(blob);
});
}

View File

@ -8,24 +8,8 @@ import {
} from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '@/application/services/js-services/fetch';
import {
initAPIService,
signInGoogle,
signInWithMagicLink,
signInGithub,
signInDiscord,
signInWithUrl,
createGlobalCommentOnPublishView,
deleteGlobalCommentOnPublishView,
getPublishViewComments,
getWorkspaces,
getWorkspaceFolder,
getCurrentUser,
duplicatePublishView,
getReactions,
addReaction,
removeReaction,
} from '@/application/services/js-services/wasm/client_api';
import { APIService } from '@/application/services/js-services/http';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { emit, EventType } from '@/application/session';
import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in';
@ -53,11 +37,7 @@ export class AFClientService implements AFService {
private cacheDatabaseRowFolder: Map<string, Y.Map<YDoc>> = new Map();
constructor(config: AFServiceConfig) {
initAPIService({
...config.cloudConfig,
deviceId: this.deviceId,
clientId: this.clientId,
});
APIService.initAPIService(config.cloudConfig);
}
getClientId() {
@ -182,7 +162,7 @@ export class AFClientService implements AFService {
async loginAuth(url: string) {
try {
console.log('loginAuth', url);
await signInWithUrl(url);
await APIService.signInWithUrl(url);
emit(EventType.SESSION_VALID);
afterAuth();
return;
@ -194,51 +174,45 @@ export class AFClientService implements AFService {
@withSignIn()
async signInMagicLink({ email }: { email: string; redirectTo: string }) {
return await signInWithMagicLink(email, AUTH_CALLBACK_URL);
return await APIService.signInWithMagicLink(email, AUTH_CALLBACK_URL);
}
@withSignIn()
async signInGoogle(_: { redirectTo: string }) {
return await signInGoogle(AUTH_CALLBACK_URL);
return APIService.signInGoogle(AUTH_CALLBACK_URL);
}
@withSignIn()
async signInGithub(_: { redirectTo: string }) {
return await signInGithub(AUTH_CALLBACK_URL);
return APIService.signInGithub(AUTH_CALLBACK_URL);
}
@withSignIn()
async signInDiscord(_: { redirectTo: string }) {
return await signInDiscord(AUTH_CALLBACK_URL);
return APIService.signInDiscord(AUTH_CALLBACK_URL);
}
async getWorkspaces() {
const data = getWorkspaces();
const data = APIService.getWorkspaces();
return data;
}
async getWorkspaceFolder(workspaceId: string) {
const data = await getWorkspaceFolder(workspaceId);
const data = await APIService.getWorkspaceFolder(workspaceId);
return data;
}
async getCurrentUser() {
const data = await getCurrentUser();
const data = await APIService.getCurrentUser();
return {
uid: data.uid,
email: data.email,
name: data.name,
avatar: data.icon_url,
uuid: data.uuid,
};
await APIService.getWorkspaces();
return data;
}
async duplicatePublishView(params: DuplicatePublishView) {
return duplicatePublishView({
workspace_id: params.workspaceId,
return APIService.duplicatePublishView(params.workspaceId, {
dest_view_id: params.spaceViewId,
published_view_id: params.viewId,
published_collab_type: params.collabType,
@ -246,26 +220,26 @@ export class AFClientService implements AFService {
}
createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise<void> {
return createGlobalCommentOnPublishView(viewId, content, replyCommentId);
return APIService.createGlobalCommentOnPublishView(viewId, content, replyCommentId);
}
deleteCommentOnPublishView(viewId: string, commentId: string): Promise<void> {
return deleteGlobalCommentOnPublishView(viewId, commentId);
return APIService.deleteGlobalCommentOnPublishView(viewId, commentId);
}
getPublishViewGlobalComments(viewId: string): Promise<GlobalComment[]> {
return getPublishViewComments(viewId);
return APIService.getPublishViewComments(viewId);
}
getPublishViewReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
return getReactions(viewId, commentId);
return APIService.getReactions(viewId, commentId);
}
addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
return addReaction(viewId, commentId, reactionType);
return APIService.addReaction(viewId, commentId, reactionType);
}
removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
return removeReaction(viewId, commentId, reactionType);
return APIService.removeReaction(viewId, commentId, reactionType);
}
}

View File

@ -1,236 +0,0 @@
import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token';
import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm';
import { AFCloudConfig } from '@/application/services/services.type';
import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type';
import { FolderView } from '@/application/types';
import { GlobalComment, Reaction } from '@/application/comment.type';
let client: ClientAPI;
export function initAPIService(
config: AFCloudConfig & {
deviceId: string;
clientId: string;
}
) {
if (client) {
return;
}
window.refresh_token = refreshToken;
window.invalid_token = invalidToken;
client = ClientAPI.new({
base_url: config.baseURL,
ws_addr: config.wsURL,
gotrue_url: config.gotrueURL,
device_id: config.deviceId,
client_id: config.clientId,
configuration: {
compression_quality: 8,
compression_buffer_size: 10240,
},
});
if (isTokenValid()) {
client.restore_token(getToken() || '');
}
client.subscribe();
}
export async function getPublishView(publishNamespace: string, publishName: string) {
const data = await client.get_publish_view(publishNamespace, publishName);
const meta = JSON.parse(data.meta.data) as PublishViewMetaData;
if (meta.view.layout === ViewLayout.Document) {
return {
data: data.data,
meta,
};
}
try {
const decoder = new TextDecoder('utf-8');
const jsonStr = decoder.decode(new Uint8Array(data.data));
const res = JSON.parse(jsonStr) as {
database_collab: number[];
database_row_collabs: Record<RowId, number[]>;
database_row_document_collabs: Record<string, number[]>;
visible_database_view_ids: ViewId[];
database_relations: Record<DatabaseId, ViewId>;
};
return {
data: res.database_collab,
rows: res.database_row_collabs,
visibleViewIds: res.visible_database_view_ids,
relations: res.database_relations,
meta,
};
} catch (e) {
return Promise.reject(e);
}
}
export async function getPublishInfoWithViewId(viewId: string) {
return client.get_publish_info(viewId);
}
export async function getPublishViewMeta(publishNamespace: string, publishName: string) {
const data = await client.get_publish_view_meta(publishNamespace, publishName);
const metadata = JSON.parse(data.data) as PublishViewMetaData;
return metadata;
}
export async function signInWithUrl(url: string) {
return client.sign_in_with_url(url);
}
export async function signInWithMagicLink(email: string, redirectTo: string) {
return client.sign_in_with_magic_link(email, redirectTo);
}
export async function signInGoogle(redirectTo: string) {
return signInProvider('google', redirectTo);
}
export async function signInProvider(provider: string, redirectTo: string) {
try {
const { url } = await client.generate_oauth_url_with_provider(provider, redirectTo);
window.open(url, '_current');
} catch (e) {
return Promise.reject(e);
}
}
export async function signInGithub(redirectTo: string) {
return signInProvider('github', redirectTo);
}
export async function signInDiscord(redirectTo: string) {
return signInProvider('discord', redirectTo);
}
export async function getWorkspaces() {
try {
const { data } = await client.get_workspaces();
return data.map((workspace) => ({
id: workspace.workspace_id,
name: workspace.workspace_name,
icon: workspace.icon,
memberCount: workspace.member_count || 0,
}));
} catch (e) {
return Promise.reject(e);
}
}
export async function getWorkspaceFolder(workspaceId: string): Promise<FolderView> {
try {
const data = await client.get_folder(workspaceId);
// eslint-disable-next-line no-inner-declarations
function iterateFolder(folder: WorkspaceFolder): FolderView {
return {
id: folder.view_id,
name: folder.name,
icon: folder.icon,
isSpace: folder.is_space,
extra: folder.extra,
isPrivate: folder.is_private,
children: folder.children.map((child: WorkspaceFolder) => {
return iterateFolder(child);
}),
};
}
return iterateFolder(data);
} catch (e) {
return Promise.reject(e);
}
}
export function getCurrentUser() {
return client.get_user();
}
export function duplicatePublishView(payload: DuplicatePublishViewPayload) {
return client.duplicate_publish_view(payload);
}
export async function getPublishViewComments(viewId: string): Promise<GlobalComment[]> {
try {
const { comments } = await client.get_publish_view_comments(viewId);
return comments.map((comment) => {
return {
commentId: comment.comment_id,
user: {
uuid: comment.user?.uuid || '',
name: comment.user?.name || '',
avatarUrl: comment.user?.avatar_url || null,
},
content: comment.content,
createdAt: comment.created_at,
lastUpdatedAt: comment.last_updated_at,
replyCommentId: comment.reply_comment_id,
isDeleted: comment.is_deleted,
canDeleted: comment.can_be_deleted,
};
});
} catch (e) {
return Promise.reject(e);
}
}
export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) {
return client.create_comment_on_publish_view(viewId, content, replyCommentId);
}
export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) {
return client.delete_comment_on_publish_view(viewId, commentId);
}
export async function getReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
try {
const { reactions } = await client.get_reactions(viewId, commentId);
const reactionsMap: Record<string, Reaction[]> = {};
for (const reaction of reactions) {
if (!reactionsMap[reaction.comment_id]) {
reactionsMap[reaction.comment_id] = [];
}
reactionsMap[reaction.comment_id].push({
reactionType: reaction.reaction_type,
commentId: reaction.comment_id,
reactUsers: reaction.react_users.map((user) => ({
uuid: user.uuid,
name: user.name,
avatarUrl: user.avatar_url,
})),
});
}
return reactionsMap;
} catch (e) {
return Promise.reject(e);
}
}
export async function addReaction(viewId: string, commentId: string, reactionType: string) {
return client.create_reaction(viewId, commentId, reactionType);
}
export async function removeReaction(viewId: string, commentId: string, reactionType: string) {
return client.delete_reaction(viewId, commentId, reactionType);
}

View File

@ -1 +0,0 @@
export * as APIService from './client_api';

View File

@ -18,3 +18,21 @@ export function isTokenValid() {
export function getToken() {
return localStorage.getItem('token');
}
export function getTokenParsed(): {
access_token: string;
expires_at: number;
refresh_token: string;
} | null {
const token = getToken();
if (!token) {
return null;
}
try {
return JSON.parse(token);
} catch (e) {
return null;
}
}

View File

@ -1,9 +1,7 @@
import { MAX_FREQUENTLY_ROW_COUNT, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { loadEmojiData } from '@/utils/emoji';
import { EmojiMartData } from '@emoji-mart/data';
import { PopoverProps } from '@mui/material/Popover';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { FrequentlyUsed, getEmojiDataFromNative, init, Store } from 'emoji-mart';
import chunk from 'lodash-es/chunk';
import React, { useCallback, useEffect, useState } from 'react';
@ -22,12 +20,12 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str
const [searchValue, setSearchValue] = useState('');
const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
const [skin, setSkin] = useState<number>(() => {
return Number(Store.get('skin')) || 0;
return Number(localStorage.getItem('emoji-mart.skin')) || 0;
});
const onSkinChange = useCallback((val: number) => {
setSkin(val);
Store.set('skin', String(val));
localStorage.setItem('emoji-mart.skin', String(val));
}, []);
const searchEmojiData = useCallback(
@ -70,10 +68,6 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str
useEffect(() => {
void (async () => {
await init({
maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT,
perLine: PER_ROW_EMOJI_COUNT,
});
await searchEmojiData();
})();
}, [searchEmojiData]);
@ -85,17 +79,6 @@ export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: str
const onSelect = useCallback(
async (native: string) => {
onEmojiSelect(native);
if (!native) {
return;
}
try {
const data = await getEmojiDataFromNative(native);
FrequentlyUsed.add(data);
} catch (e) {
// do nothing
}
},
[onEmojiSelect]
);
@ -156,6 +139,7 @@ export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize:
id: string;
type: 'category' | 'emojis';
emojis?: Emoji[];
category?: string;
}[] = [];
emojiCategories.forEach((category) => {
@ -165,6 +149,7 @@ export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize:
});
chunk(category.emojis, rowSize).forEach((chunk, index) => {
rows.push({
category: category.id,
type: 'emojis',
emojis: chunk,
id: `${category.id}-${index}`,

View File

@ -67,17 +67,36 @@ function EmojiPickerCategories({
const renderRow = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const item = rows[index];
const tagName = getCategoryName(item.id);
const isFlags = item.category === 'flags';
return (
<div style={style} data-index={index}>
{item.type === 'category' ? (
<div className={'pt-2 text-base font-medium text-text-caption'}>{getCategoryName(item.id)}</div>
<div className={'pt-2 text-base font-medium text-text-caption'}>{tagName}</div>
) : null}
<div className={'flex'}>
{item.emojis?.map((emoji, columnIndex) => {
const isSelected = selectCell.row === index && selectCell.column === columnIndex;
const isDefaultEmoji = defaultEmoji === emoji.native;
const classList = [
'flex cursor-pointer items-center justify-center rounded text-[20px] hover:bg-fill-list-hover',
];
if (isSelected) {
classList.push('bg-fill-list-hover');
} else {
classList.push('hover:bg-transparent');
}
if (isDefaultEmoji) {
classList.push('bg-fill-list-active');
}
if (isFlags) {
classList.push('icon');
}
return (
<Tooltip key={emoji.id} title={emoji.name} placement={'top'} enterDelay={500} disableInteractive={true}>
@ -105,9 +124,7 @@ function EmojiPickerCategories({
mouseX.current = e.clientX;
mouseY.current = e.clientY;
}}
className={`flex cursor-pointer items-center justify-center rounded text-[20px] hover:bg-fill-list-hover ${
isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent'
} ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`}
className={classList.join(' ')}
>
{emoji.native}
</div>

View File

@ -1,3 +1,5 @@
import { lazy } from 'react';
// import { lazy } from 'react';
//
// export const EmojiPicker = lazy(() => import('./EmojiPicker'));
export const EmojiPicker = lazy(() => import('./EmojiPicker'));
export * from './EmojiPicker';

View File

@ -1,6 +1,7 @@
import { ViewLayout, ViewMetaIcon } from '@/application/collab.type';
import { ViewIcon } from '@/components/_shared/view-icon';
import React from 'react';
import { isFlagEmoji } from '@/utils/emoji';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function DatabaseHeader({
@ -14,6 +15,9 @@ function DatabaseHeader({
layout?: ViewLayout;
}) {
const { t } = useTranslation();
const isFlag = useMemo(() => {
return icon ? isFlagEmoji(icon.value) : false;
}, [icon]);
return (
<div
@ -23,7 +27,7 @@ function DatabaseHeader({
>
<div className={'relative'}>
{icon?.value ? (
<div className={'view-icon'}>{icon?.value}</div>
<div className={`view-icon ${isFlag ? 'icon' : ''}`}>{icon?.value}</div>
) : (
<ViewIcon layout={layout || ViewLayout.Grid} size={10} />
)}

View File

@ -2,6 +2,7 @@ import { ViewLayout } from '@/application/collab.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import { ViewIcon } from '@/components/_shared/view-icon';
import { useEditorContext } from '@/components/editor/EditorContext';
import { isFlagEmoji } from '@/utils/emoji';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -32,6 +33,10 @@ function MentionPage({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const isFlag = useMemo(() => {
return icon ? isFlagEmoji(icon.value) : false;
}, [icon]);
return (
<span
onClick={() => {
@ -44,7 +49,7 @@ function MentionPage({ pageId }: { pageId: string }) {
<span className={'mention-unpublished cursor-text font-semibold text-text-caption'}>No Access</span>
) : (
<>
<span className={'mention-icon icon'}>
<span className={`mention-icon ${isFlag ? 'icon' : ''}`}>
{icon?.value || <ViewIcon layout={meta?.layout || ViewLayout.Document} size={'small'} />}
</span>

View File

@ -185,7 +185,7 @@ export function useCommentRender(comment: GlobalComment) {
}, [comment]);
const timeFormat = useMemo(() => {
const time = dayjs.unix(Number(comment.lastUpdatedAt));
const time = dayjs(comment.lastUpdatedAt);
return time.format('YYYY-MM-DD HH:mm:ss');
}, [comment.lastUpdatedAt]);
@ -193,7 +193,7 @@ export function useCommentRender(comment: GlobalComment) {
const time = useMemo(() => {
if (!comment.lastUpdatedAt) return '';
const now = dayjs();
const past = dayjs.unix(Number(comment.lastUpdatedAt));
const past = dayjs(comment.lastUpdatedAt);
const diffSec = now.diff(past, 'second');
const diffMin = now.diff(past, 'minute');
const diffHour = now.diff(past, 'hour');

View File

@ -1,5 +1,6 @@
import { Reaction as ReactionType } from '@/application/comment.type';
import { AFConfigContext } from '@/components/app/AppConfig';
import { isFlagEmoji } from '@/utils/emoji';
import { getPlatform } from '@/utils/platform';
import { Tooltip } from '@mui/material';
import React, { memo, useContext, useMemo } from 'react';
@ -68,6 +69,10 @@ function Reaction({ reaction, onClick }: { reaction: ReactionType; onClick: (rea
return getPlatform().isMobile;
}, []);
const isFlag = useMemo(() => {
return isFlagEmoji(reaction.reactionType);
}, [reaction.reactionType]);
return (
<Tooltip
title={
@ -91,7 +96,7 @@ function Reaction({ reaction, onClick }: { reaction: ReactionType; onClick: (rea
'flex cursor-pointer items-center gap-1 rounded-full border border-transparent bg-fill-list-hover px-1 py-0.5 text-sm'
}
>
<span className={''}>{reaction.reactionType}</span>
<span className={`${isFlag ? 'icon' : ''}`}>{reaction.reactionType}</span>
{<div className={'text-xs font-medium'}>{reactCount}</div>}
</div>
</Tooltip>

View File

@ -4,6 +4,7 @@ import { notify } from '@/components/_shared/notify';
import { ViewIcon } from '@/components/_shared/view-icon';
import SpaceIcon from '@/components/publish/header/SpaceIcon';
import { renderColor } from '@/utils/color';
import { isFlagEmoji } from '@/utils/emoji';
import { Tooltip } from '@mui/material';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -34,6 +35,9 @@ function BreadcrumbItem({ crumb, disableClick = false }: { crumb: Crumb; disable
const { t } = useTranslation();
const onNavigateToView = usePublishContext()?.toView;
const isFlag = useMemo(() => {
return icon ? isFlagEmoji(icon) : false;
}, [icon]);
return (
<Tooltip title={name} placement={'bottom'} enterDelay={1000} enterNextDelay={1000}>
@ -59,7 +63,7 @@ function BreadcrumbItem({ crumb, disableClick = false }: { crumb: Crumb; disable
<SpaceIcon value={extraObj.space_icon || ''} />
</span>
) : (
<span className={'icon flex h-5 w-5 items-center justify-center'}>
<span className={`${isFlag ? 'icon' : ''} flex h-5 w-5 items-center justify-center`}>
{icon || <ViewIcon layout={layout} size={'small'} />}
</span>
)}

View File

@ -93,9 +93,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
<div className={'flex items-center gap-2'}>
<MoreActions />
{/*<Suspense fallback={null}>*/}
{/* <Duplicate />*/}
{/*</Suspense>*/}
{/*<Duplicate />*/}
<Divider orientation={'vertical'} className={'mx-2'} flexItem />
<Tooltip title={t('publish.downloadApp')}>
<button onClick={openOrDownload}>

View File

@ -5,6 +5,7 @@ import BuiltInImage4 from '@/assets/cover/m_cover_image_4.png';
import BuiltInImage5 from '@/assets/cover/m_cover_image_5.png';
import BuiltInImage6 from '@/assets/cover/m_cover_image_6.png';
import ViewCover, { CoverType } from '@/components/view-meta/ViewCover';
import { isFlagEmoji } from '@/utils/emoji';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ViewLayout, ViewMetaIcon } from '@/application/collab.type';
@ -54,6 +55,10 @@ export function ViewMetaPreview({ icon, cover, name }: ViewMetaProps) {
}, [coverType, cover?.value]);
const { t } = useTranslation();
const isFlag = useMemo(() => {
return icon ? isFlagEmoji(icon.value) : false;
}, [icon]);
return (
<div className={'flex w-full flex-col items-center'}>
{cover && <ViewCover coverType={coverType} coverValue={coverValue} />}
@ -63,7 +68,7 @@ export function ViewMetaPreview({ icon, cover, name }: ViewMetaProps) {
'flex gap-4 overflow-hidden whitespace-pre-wrap break-words break-all px-16 text-[2.25rem] font-bold leading-[1.5em] max-md:px-4 max-sm:text-[7vw]'
}
>
{icon?.value ? <div className={'view-icon'}>{icon?.value}</div> : null}
{icon?.value ? <div className={`view-icon ${isFlag ? 'icon' : ''}`}>{icon?.value}</div> : null}
<div className={'relative top-1.5'}>
{name || <span className={'text-text-placeholder'}>{t('menuAppHeader.defaultNewPageName')}</span>}

View File

@ -9,8 +9,8 @@ function LoginPage() {
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
useEffect(() => {
if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) {
window.location.href = redirectTo;
if (isAuthenticated && redirectTo && decodeURIComponent(redirectTo) !== window.location.href) {
window.location.href = decodeURIComponent(redirectTo);
}
}, [isAuthenticated, redirectTo]);
return (

View File

@ -69,7 +69,6 @@ body {
.view-icon {
@apply flex w-fit leading-[1.5em] cursor-pointer rounded-lg py-2 text-[1.5em];
font-family: 'Apple Color Emoji', 'Noto Color Emoji', 'Segoe UI Emoji', 'Twemoji Mozilla', sans-serif;
line-height: 1em;
white-space: nowrap;
}

View File

@ -12,3 +12,7 @@ export async function randomEmoji(skin = 0) {
export async function loadEmojiData() {
return import('@emoji-mart/data/sets/15/native.json');
}
export function isFlagEmoji(emoji: string) {
return /\uD83C[\uDDE6-\uDDFF]/.test(emoji);
}

View File

@ -1,7 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import wasm from 'vite-plugin-wasm';
import { visualizer } from 'rollup-plugin-visualizer';
import usePluginImport from 'vite-plugin-importer';
import { totalBundleSize } from 'vite-plugin-total-bundle-size';
@ -14,7 +13,6 @@ const isDev = process.env.NODE_ENV === 'development';
export default defineConfig({
plugins: [
react(),
wasm(),
svgr({
svgrOptions: {
prettier: false,
@ -117,7 +115,8 @@ export default defineConfig({
id.includes('/react-custom-scrollbars') ||
id.includes('/dayjs') ||
id.includes('/smooth-scroll-into-view-if-needed') ||
id.includes('/react-virtualized-auto-sizer')
id.includes('/react-virtualized-auto-sizer') ||
id.includes('/react-window')
) {
return 'common';
}
@ -141,13 +140,6 @@ export default defineConfig({
},
optimizeDeps: {
include: [
'react',
'react-dom',
'react-katex',
// 'react-custom-scrollbars-2',
// 'react-window',
// 'react-virtualized-auto-sizer',
],
include: ['react', 'react-dom', 'react-katex'],
},
});