/*
* 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 .
*/
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, 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 => {
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 | 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'}`,
);
};