286 lines
8.2 KiB
TypeScript
286 lines
8.2 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import * as GiftActionCreators from '~/actions/GiftActionCreators';
|
|
import * as InviteActionCreators from '~/actions/InviteActionCreators';
|
|
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
|
import {modal} from '~/actions/ModalActionCreators';
|
|
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
|
|
import {ME} from '~/Constants';
|
|
import {UserProfileModal} from '~/components/modals/UserProfileModal';
|
|
import {Routes} from '~/Routes';
|
|
import AuthenticationStore from '~/stores/AuthenticationStore';
|
|
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
|
import UserStore from '~/stores/UserStore';
|
|
import * as RouterUtils from '~/utils/RouterUtils';
|
|
import SnowflakeUtil from '~/utils/SnowflakeUtil';
|
|
import {APP_PROTOCOL_PREFIX} from './appProtocol';
|
|
import {getElectronAPI} from './NativeUtils';
|
|
|
|
type DeepLinkTarget =
|
|
| {type: 'invite'; code: string; preferLogin: boolean}
|
|
| {type: 'gift'; code: string; preferLogin: boolean}
|
|
| {type: 'user'; userId: string};
|
|
|
|
const parseDeepLink = (rawUrl: string): DeepLinkTarget | null => {
|
|
const tryFromSegments = (segments: Array<string>, search?: string): DeepLinkTarget | null => {
|
|
const [first, second, third] = segments.filter(Boolean);
|
|
const preferLogin = third === 'login' || search?.includes('login=1') || search?.includes('action=login') || false;
|
|
|
|
if (first === 'invite' && second) {
|
|
return {type: 'invite', code: second, preferLogin};
|
|
}
|
|
|
|
if (first === 'gift' && second) {
|
|
return {type: 'gift', code: second, preferLogin};
|
|
}
|
|
|
|
if (first === 'users' && second) {
|
|
return {type: 'user', userId: second};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
try {
|
|
const parsed = new URL(rawUrl);
|
|
const segments = [parsed.host, ...parsed.pathname.split('/')];
|
|
const target = tryFromSegments(segments, parsed.search);
|
|
if (target) return target;
|
|
} catch {}
|
|
|
|
const protocolPattern = new RegExp(`^${APP_PROTOCOL_PREFIX.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}`);
|
|
const sanitized = rawUrl.replace(protocolPattern, '').replace(/^\/+/, '');
|
|
const [pathPart, searchPart] = sanitized.split('?');
|
|
const target = tryFromSegments(pathPart.split('/'), searchPart ? `?${searchPart}` : undefined);
|
|
return target;
|
|
};
|
|
|
|
const navigateForTarget = (target: DeepLinkTarget) => {
|
|
const isAuthenticated = AuthenticationStore.isAuthenticated;
|
|
|
|
if (target.type === 'gift' && RuntimeConfigStore.isSelfHosted()) {
|
|
return;
|
|
}
|
|
|
|
if (isAuthenticated) {
|
|
if (target.type === 'invite') {
|
|
void InviteActionCreators.openAcceptModal(target.code);
|
|
} else {
|
|
if (target.type === 'gift') {
|
|
void GiftActionCreators.openAcceptModal(target.code);
|
|
} else if (target.type === 'user') {
|
|
void openUserProfile(target.userId);
|
|
}
|
|
}
|
|
RouterUtils.transitionTo(Routes.ME);
|
|
return;
|
|
}
|
|
|
|
if (target.type === 'user') {
|
|
RouterUtils.transitionTo(Routes.LOGIN);
|
|
return;
|
|
}
|
|
|
|
if (target.type === 'invite') {
|
|
const dest = target.preferLogin ? Routes.inviteLogin(target.code) : Routes.inviteRegister(target.code);
|
|
RouterUtils.transitionTo(dest);
|
|
return;
|
|
}
|
|
|
|
const dest = target.preferLogin ? Routes.giftLogin(target.code) : Routes.giftRegister(target.code);
|
|
RouterUtils.transitionTo(dest);
|
|
};
|
|
|
|
export const handleDeepLinkUrl = (rawUrl: string): boolean => {
|
|
const target = parseDeepLink(rawUrl);
|
|
if (!target) return false;
|
|
navigateForTarget(target);
|
|
return true;
|
|
};
|
|
|
|
export const handleRpcNavigation = (path: string): void => {
|
|
RouterUtils.transitionTo(path);
|
|
};
|
|
|
|
let listenerStarted = false;
|
|
|
|
export const startDeepLinkHandling = async (): Promise<void> => {
|
|
if (listenerStarted) return;
|
|
|
|
const electronApi = getElectronAPI();
|
|
if (electronApi) {
|
|
listenerStarted = true;
|
|
|
|
try {
|
|
const initialUrl = await electronApi.getInitialDeepLink();
|
|
if (initialUrl) {
|
|
handleDeepLinkUrl(initialUrl);
|
|
}
|
|
} catch (error) {
|
|
console.error('[DeepLink] Failed to get initial deep link', error);
|
|
}
|
|
|
|
electronApi.onDeepLink((url) => {
|
|
try {
|
|
handleDeepLinkUrl(url);
|
|
} catch (error) {
|
|
console.error('[DeepLink] Failed to handle URL', url, error);
|
|
}
|
|
});
|
|
|
|
if (typeof electronApi.onRpcNavigate === 'function') {
|
|
electronApi.onRpcNavigate((path) => {
|
|
try {
|
|
handleRpcNavigation(path);
|
|
} catch (error) {
|
|
console.error('[DeepLink] Failed to handle RPC navigation', path, error);
|
|
}
|
|
});
|
|
} else {
|
|
console.warn('[DeepLink] onRpcNavigate not available on this host version');
|
|
}
|
|
|
|
return;
|
|
}
|
|
};
|
|
|
|
const EXTRA_INTERNAL_CHANNEL_HOSTS = ['fluxer.app', 'canary.fluxer.app'];
|
|
|
|
export const isInternalChannelHost = (host: string): boolean => {
|
|
if (!host) return false;
|
|
if (typeof location !== 'undefined' && host === location.host) {
|
|
return true;
|
|
}
|
|
if (RuntimeConfigStore.marketingHost && host === RuntimeConfigStore.marketingHost) {
|
|
return true;
|
|
}
|
|
return EXTRA_INTERNAL_CHANNEL_HOSTS.includes(host);
|
|
};
|
|
|
|
export function parseChannelUrl(url: string): string | null {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const isInternal = isInternalChannelHost(parsed.host) && parsed.pathname.startsWith('/channels/');
|
|
|
|
if (!isInternal) return null;
|
|
|
|
const normalizedPath = parsed.pathname;
|
|
const segments = normalizedPath.split('/').filter(Boolean);
|
|
|
|
if (segments[0] !== 'channels') return null;
|
|
|
|
const [, scope, channelId, messageId] = segments;
|
|
const segmentCount = segments.length;
|
|
const isSnowflake = (value?: string) => SnowflakeUtil.isProbablyAValidSnowflake(value ?? null);
|
|
const isDmScope = scope === ME;
|
|
|
|
let isValid = false;
|
|
|
|
if (isDmScope) {
|
|
if (segmentCount === 2) {
|
|
isValid = true;
|
|
} else if (segmentCount === 3 && isSnowflake(channelId)) {
|
|
isValid = true;
|
|
} else if (segmentCount === 4 && isSnowflake(channelId) && isSnowflake(messageId)) {
|
|
isValid = true;
|
|
}
|
|
} else {
|
|
if (segmentCount === 3 && isSnowflake(scope) && isSnowflake(channelId)) {
|
|
isValid = true;
|
|
} else if (segmentCount === 4 && isSnowflake(scope) && isSnowflake(channelId) && isSnowflake(messageId)) {
|
|
isValid = true;
|
|
}
|
|
}
|
|
|
|
if (isValid) {
|
|
return normalizedPath;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export interface ChannelJumpLink {
|
|
scope: string;
|
|
channelId: string;
|
|
}
|
|
|
|
export interface MessageJumpLink extends ChannelJumpLink {
|
|
messageId: string;
|
|
}
|
|
|
|
const getChannelSegments = (url: string): Array<string> | null => {
|
|
const channelPath = parseChannelUrl(url);
|
|
if (!channelPath) return null;
|
|
return channelPath.split('/').filter(Boolean);
|
|
};
|
|
|
|
export function parseChannelJumpLink(url: string): ChannelJumpLink | null {
|
|
const segments = getChannelSegments(url);
|
|
if (!segments || segments.length < 3) return null;
|
|
|
|
const [, scope, channelId] = segments;
|
|
if (!scope || !channelId) return null;
|
|
|
|
return {
|
|
scope,
|
|
channelId,
|
|
};
|
|
}
|
|
|
|
export function parseMessageJumpLink(url: string): MessageJumpLink | null {
|
|
const segments = getChannelSegments(url);
|
|
if (!segments || segments.length !== 4) return null;
|
|
|
|
const [, scope, channelId, messageId] = segments;
|
|
if (!messageId || !SnowflakeUtil.isProbablyAValidSnowflake(messageId)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
scope,
|
|
channelId,
|
|
messageId,
|
|
};
|
|
}
|
|
|
|
const openUserProfile = async (userId: string, guildId?: string) => {
|
|
try {
|
|
await UserProfileActionCreators.fetch(userId, guildId);
|
|
} catch (error) {
|
|
console.error('[DeepLink] Failed to fetch user profile', userId, error);
|
|
}
|
|
|
|
const user = UserStore.getUser(userId);
|
|
ModalActionCreators.pushWithKey(
|
|
modal(() =>
|
|
React.createElement(UserProfileModal, {
|
|
userId,
|
|
guildId,
|
|
previewUser: user ?? undefined,
|
|
}),
|
|
),
|
|
`user-profile-${userId}-${guildId ?? 'global'}`,
|
|
);
|
|
};
|