Added a chat invite link resolver which allows to easily connect within the client

master
WolverinDEV 2021-02-20 16:57:52 +01:00
parent 2b96dc91aa
commit a1e1df6a2d
16 changed files with 845 additions and 78 deletions

View File

@ -984,7 +984,7 @@ export class ConnectionHandler {
const connectParameters = this.serverConnection.handshake_handler().parameters;
return {
channel: targetChannel ? {target: "/" + targetChannel.channelId, password: targetChannel.cached_password()} : undefined,
channel: targetChannel ? {target: "/" + targetChannel.channelId, password: targetChannel.getCachedPasswordHash()} : undefined,
nickname: name,
password: connectParameters.serverPassword ? {
password: connectParameters.serverPassword,

View File

@ -14,7 +14,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
function: async () => {
clientServices = new ClientServices(new class implements ClientServiceConfig {
getServiceHost(): string {
//return "localhost:1244";
return "localhost:1244";
return "client-services.teaspeak.de:27791";
}

View File

@ -441,7 +441,7 @@ export class ChannelConversationManager extends AbstractChatManager<ChannelConve
}
queryUnreadFlags() {
const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.cached_password() }});
const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.getCachedPasswordHash() }});
this.connection.serverConnection.send_command("conversationfetch", commandData).catch(error => {
logWarn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error);
});

View File

@ -9,9 +9,9 @@ import * as aplayer from "tc-backend/audio/player";
import * as ppt from "tc-backend/ppt";
import * as global_ev_handler from "./events/ClientGlobalControlHandler";
import {AppParameters, settings, Settings, UrlParameterBuilder, UrlParameterParser} from "tc-shared/settings";
import {LogCategory, logError, logInfo, logWarn} from "tc-shared/log";
import {LogCategory, logDebug, logError, logInfo, logWarn} from "tc-shared/log";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {createInfoModal} from "tc-shared/ui/elements/Modal";
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
import {RecorderProfile, setDefaultRecorder} from "tc-shared/voice/RecorderProfile";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import {formatMessage} from "tc-shared/ui/frames/chat";
@ -48,6 +48,9 @@ import "./ui/modal/connect/Controller";
import "./ui/elements/ContextDivider";
import "./ui/elements/Tab";
import "./clientservice";
import "./text/bbcode/InviteController";
import {clientServiceInvite} from "tc-shared/clientservice";
import {ActionResult} from "tc-services";
assertMainApplication();
@ -109,31 +112,102 @@ export function handleNativeConnectRequest(url: URL) {
}
}
handleConnectRequest(serverAddress, new UrlParameterParser(url));
handleConnectRequest(serverAddress, undefined, new UrlParameterParser(url)).then(undefined);
}
function handleConnectRequest(serverAddress: string, parameters: UrlParameterParser) {
export async function handleConnectRequest(serverAddress: string, serverUniqueId: string | undefined, parameters: UrlParameterParser) {
const inviteLinkId = parameters.getValue(AppParameters.KEY_CONNECT_INVITE_REFERENCE, undefined);
logDebug(LogCategory.STATISTICS, tr("Executing connect request with invite key reference: %o"), inviteLinkId);
if(inviteLinkId) {
clientServiceInvite.logAction(inviteLinkId, "ConnectAttempt").then(result => {
if(result.status !== "success") {
logWarn(LogCategory.STATISTICS, tr("Failed to register connect attempt: %o"), result.result);
}
});
}
const result = await doHandleConnectRequest(serverAddress, serverUniqueId, parameters);
if(inviteLinkId) {
let promise: Promise<ActionResult<void>>;
switch (result.status) {
case "success":
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectSuccess");
break;
case "channel-already-joined":
case "server-already-joined":
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectNoAction", { reason: result.status });
break;
default:
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectFailure", { reason: result.status });
break;
}
promise.then(result => {
if(result.status !== "success") {
logWarn(LogCategory.STATISTICS, tr("Failed to register connect result: %o"), result.result);
}
});
}
}
type ConnectRequestResult = {
status:
"success" |
"profile-invalid" |
"client-aborted" |
"server-join-failed" |
"server-already-joined" |
"channel-already-joined" |
"channel-not-visible" |
"channel-join-failed"
}
/**
* @param serverAddress The target address to connect to
* @param serverUniqueId If given a server unique id. If any of our current connections matches it, such connection will be used
* @param parameters General connect parameters from the connect URL
*/
async function doHandleConnectRequest(serverAddress: string, serverUniqueId: string | undefined, parameters: UrlParameterParser) : Promise<ConnectRequestResult> {
let targetServerConnection: ConnectionHandler;
let isCurrentServerConnection: boolean;
if(serverUniqueId) {
if(server_connections.getActiveConnectionHandler()?.getCurrentServerUniqueId() === serverUniqueId) {
targetServerConnection = server_connections.getActiveConnectionHandler();
isCurrentServerConnection = true;
} else {
targetServerConnection = server_connections.getAllConnectionHandlers().find(connection => connection.getCurrentServerUniqueId() === serverUniqueId);
isCurrentServerConnection = false;
}
}
const profileId = parameters.getValue(AppParameters.KEY_CONNECT_PROFILE, undefined);
const profile = findConnectProfile(profileId) || defaultConnectProfile();
const profile = findConnectProfile(profileId) || targetServerConnection?.serverConnection.handshake_handler()?.parameters.profile || defaultConnectProfile();
if(!profile || !profile.valid()) {
spawnConnectModalNew({
selectedAddress: serverAddress,
selectedProfile: profile
});
return;
return { status: "profile-invalid" };
}
if(!aplayer.initialized()) {
/* Trick the client into clicking somewhere on the site */
spawnYesNo(tra("Connect to {}", serverAddress), tra("Would you like to connect to {}?", serverAddress), result => {
if(result) {
aplayer.on_ready(() => handleConnectRequest(serverAddress, parameters));
} else {
/* Well... the client don't want to... */
}
}).open();
return;
/* Trick the client into clicking somewhere on the site to initialize audio */
const resultPromise = new Promise<boolean>(resolve => {
spawnYesNo(tra("Connect to {}", serverAddress), tra("Would you like to connect to {}?", serverAddress), resolve).open();
});
if(!(await resultPromise)) {
/* Well... the client don't want to... */
return { status: "client-aborted" };
}
await new Promise(resolve => aplayer.on_ready(resolve));
}
const clientNickname = parameters.getValue(AppParameters.KEY_CONNECT_NICKNAME, undefined);
@ -144,28 +218,82 @@ function handleConnectRequest(serverAddress: string, parameters: UrlParameterPar
const channel = parameters.getValue(AppParameters.KEY_CONNECT_CHANNEL, undefined);
const channelPassword = parameters.getValue(AppParameters.KEY_CONNECT_CHANNEL_PASSWORD, undefined);
let connection = server_connections.getActiveConnectionHandler();
if(connection.connected) {
connection = server_connections.spawnConnectionHandler();
if(!targetServerConnection) {
targetServerConnection = server_connections.getActiveConnectionHandler();
if(targetServerConnection.connected) {
targetServerConnection = server_connections.spawnConnectionHandler();
}
}
connection.startConnectionNew({
targetAddress: serverAddress,
server_connections.setActiveConnectionHandler(targetServerConnection);
if(isCurrentServerConnection) {
/* Just join the new channel and may use the token (before) */
nickname: clientNickname,
nicknameSpecified: false,
/* TODO: Use the token! */
let containsToken = false;
profile: profile,
token: undefined,
if(!channel) {
/* No need to join any channel */
if(!containsToken) {
createInfoModal(tr("Already connected"), tr("You're already connected to the target server.")).open();
} else {
/* Don't show a message since a token has been used */
}
serverPassword: serverPassword,
serverPasswordHashed: passwordsHashed,
return { status: "server-already-joined" };
}
defaultChannel: channel,
defaultChannelPassword: channelPassword,
defaultChannelPasswordHashed: passwordsHashed
}, false).then(undefined);
server_connections.setActiveConnectionHandler(connection);
const targetChannel = targetServerConnection.channelTree.resolveChannelPath(channel);
if(!targetChannel) {
createErrorModal(tr("Missing target channel"), tr("Failed to join channel since it is not visible.")).open();
return { status: "channel-not-visible" };
}
if(targetServerConnection.getClient().currentChannel() === targetChannel) {
createErrorModal(tr("Channel already joined"), tr("You already joined the channel.")).open();
return { status: "channel-already-joined" };
}
if(targetChannel.getCachedPasswordHash()) {
const succeeded = await targetChannel.joinChannel();
if(succeeded) {
/* Successfully joined channel with a password we already knew */
return { status: "success" };
}
}
targetChannel.setCachedHashedPassword(channelPassword);
if(await targetChannel.joinChannel()) {
return { status: "success" };
} else {
/* TODO: More detail? */
return { status: "channel-join-failed" };
}
} else {
await targetServerConnection.startConnectionNew({
targetAddress: serverAddress,
nickname: clientNickname,
nicknameSpecified: false,
profile: profile,
token: undefined,
serverPassword: serverPassword,
serverPasswordHashed: passwordsHashed,
defaultChannel: channel,
defaultChannelPassword: channelPassword,
defaultChannelPasswordHashed: passwordsHashed
}, false);
if(targetServerConnection.connected) {
return { status: "success" };
} else {
/* TODO: More detail? */
return { status: "server-join-failed" };
}
}
}
/* Used by the old native clients (an within the multi instance handler). Delete it later */
@ -178,7 +306,7 @@ export function handle_connect_request(properties: ConnectRequestData, _connecti
urlBuilder.setValue(AppParameters.KEY_CONNECT_PASSWORDS_HASHED, properties.password?.hashed);
const url = new URL(`https://localhost/?${urlBuilder.build()}`);
handleConnectRequest(properties.address, new UrlParameterParser(url));
handleConnectRequest(properties.address, undefined, new UrlParameterParser(url));
}
function main() {
@ -339,7 +467,7 @@ const task_connect_handler: loader.Task = {
preventWelcomeUI = true;
loader.register_task(loader.Stage.LOADED, {
priority: 0,
function: async () => handleConnectRequest(address, AppParameters.Instance),
function: async () => handleConnectRequest(address, undefined, AppParameters.Instance),
name: tr("default url connect")
});
loader.register_task(loader.Stage.LOADED, task_teaweb_starter);

View File

@ -180,6 +180,12 @@ export namespace AppParameters {
description: "A target address to automatically connect to."
};
export const KEY_CONNECT_INVITE_REFERENCE: RegistryKey<string> = {
key: "cir",
fallbackKeys: ["connect-invite-reference"],
valueType: "string",
description: "The invite link used to generate the connect parameters"
};
export const KEY_CONNECT_NO_SINGLE_INSTANCE: ValuedRegistryKey<boolean> = {
key: "cnsi",
@ -249,7 +255,7 @@ export namespace AppParameters {
export const KEY_IPC_REMOTE_ADDRESS: RegistryKey<string> = {
key: "ipc-address",
valueType: "string",
description: "Address of the owner for IPC communication."
description: "Address of the apps IPC channel"
};
export const KEY_IPC_REMOTE_POPOUT_CHANNEL: RegistryKey<string> = {

View File

@ -0,0 +1,196 @@
import * as loader from "tc-loader";
import {ChannelMessage, getIpcInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC";
import {AppParameters, UrlParameterParser} from "tc-shared/settings";
import {IpcInviteInfo} from "tc-shared/text/bbcode/InviteDefinitions";
import {LogCategory, logError} from "tc-shared/log";
import {clientServiceInvite, clientServices} from "tc-shared/clientservice";
import {handleConnectRequest} from "tc-shared/main";
let ipcChannel: IPCChannel;
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "Invite controller init",
function: async () => {
ipcChannel = getIpcInstance().createChannel(AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, undefined), "invite-info");
ipcChannel.messageHandler = handleIpcMessage;
},
priority: 10
});
type QueryCacheEntry = { result, finished: boolean, timeout: number };
let queryCache: {[key: string]: QueryCacheEntry} = {};
let executingPendingInvites = false;
const pendingInviteQueries: (() => Promise<void>)[] = [];
function handleIpcMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
if(message.type === "query") {
const linkId = message.data["linkId"];
if(queryCache[linkId]) {
if(queryCache[linkId].finished) {
ipcChannel?.sendMessage("query-result", { linkId, result: queryCache[linkId].result }, remoteId);
return;
}
/* Query already enqueued. */
return;
}
const entry = queryCache[linkId] = {
finished: false,
result: undefined,
timeout: 0
} as QueryCacheEntry;
entry.timeout = setTimeout(() => {
if(queryCache[linkId] === entry) {
delete queryCache[linkId];
}
}, 30 * 60 * 1000);
pendingInviteQueries.push(() => queryInviteLink(linkId));
invokeLinkQueries();
} else if(message.type === "connect") {
const connectParameterString = message.data.connectParameters;
const serverAddress = message.data.serverAddress;
const serverUniqueId = message.data.serverUniqueId;
handleConnectRequest(serverAddress, serverUniqueId, new UrlParameterParser(new URL(`https://localhost/?${connectParameterString}`))).then(undefined);
}
}
function invokeLinkQueries() {
if(executingPendingInvites) {
return;
}
executingPendingInvites = true;
executePendingInvites().catch(error => {
logError(LogCategory.GENERAL, tr("Failed to execute pending invite queries: %o"), error);
executingPendingInvites = false;
if(pendingInviteQueries.length > 0) {
invokeLinkQueries();
}
});
}
async function executePendingInvites() {
while(pendingInviteQueries.length > 0) {
const invite = pendingInviteQueries.pop_front();
await invite();
await new Promise(resolve => setTimeout(resolve, 3000));
}
executingPendingInvites = false;
}
async function queryInviteLink(linkId: string) {
let result: IpcInviteInfo;
try {
result = await doQueryInviteLink(linkId);
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to query invite link info: %o"), error);
result = {
status: "error",
message: tr("lookup the console for details")
};
}
if(queryCache[linkId]) {
queryCache[linkId].finished = true;
queryCache[linkId].result = result;
} else {
const entry = queryCache[linkId] = {
finished: true,
result: result,
timeout: 0
};
entry.timeout = setTimeout(() => {
if(queryCache[linkId] === entry) {
delete queryCache[linkId];
}
}, 30 * 60 * 1000);
}
ipcChannel?.sendMessage("query-result", { linkId, result });
}
async function doQueryInviteLink(linkId: string) : Promise<IpcInviteInfo> {
if(!clientServices.isSessionInitialized()) {
const connectAwait = new Promise(resolve => {
clientServices.awaitSession().then(() => resolve(true));
setTimeout(() => resolve(false), 5000);
});
if(!await connectAwait) {
return { status: "error", message: tr("Client service not connected") };
}
}
/* TODO: Cache if the client has ever seen the view! */
const result = await clientServiceInvite.queryInviteLink(linkId, true);
if(result.status === "error") {
switch (result.result.type) {
case "InviteKeyExpired":
return { status: "expired" };
case "InviteKeyNotFound":
return { status: "not-found" };
default:
logError(LogCategory.GENERAL, tr("Failed to query invite link info for %s: %o"), linkId, result.result);
return { status: "error", message: tra("Server query error ({})", result.result.type) };
}
}
const inviteInfo = result.unwrap();
const serverName = inviteInfo.propertiesInfo["server-name"];
if(typeof serverName !== "string") {
return { status: "error", message: tr("Missing server name") };
}
const serverUniqueId = inviteInfo.propertiesInfo["server-unique-id"];
if(typeof serverUniqueId !== "string") {
return { status: "error", message: tr("Missing server unique id") };
}
const serverAddress = inviteInfo.propertiesConnect["server-address"];
if(typeof serverAddress !== "string") {
return { status: "error", message: tr("Missing server address") };
}
const urlParameters = {};
{
urlParameters["cir"] = linkId;
urlParameters["cn"] = inviteInfo.propertiesConnect["nickname"];
urlParameters["ctk"] = inviteInfo.propertiesConnect["token"];
urlParameters["cc"] = inviteInfo.propertiesConnect["channel"];
urlParameters["cph"] = inviteInfo.propertiesConnect["passwords-hashed"];
urlParameters["csp"] = inviteInfo.propertiesConnect["server-password"];
urlParameters["ccp"] = inviteInfo.propertiesConnect["channel-password"];
}
const urlParameterString = Object.keys(urlParameters)
.filter(key => typeof urlParameters[key] === "string" && urlParameters[key].length > 0)
.map(key => `${key}=${encodeURIComponent(urlParameters[key])}`)
.join("&");
return {
linkId: linkId,
status: "success",
expireTimestamp: inviteInfo.timestampExpired,
serverUniqueId: serverUniqueId,
serverName: serverName,
serverAddress: serverAddress,
channelName: inviteInfo.propertiesInfo["channel-name"],
connectParameters: urlParameterString,
};
}

View File

@ -0,0 +1,26 @@
export type IpcInviteInfoLoaded = {
linkId: string,
serverAddress: string,
serverUniqueId: string,
serverName: string,
connectParameters: string,
channelId?: number,
channelName?: string,
expireTimestamp: number | 0
};
export type IpcInviteInfo = (
{
status: "success",
} & IpcInviteInfoLoaded
) | {
status: "error",
message: string
} | {
status: "not-found" | "expired"
}

View File

@ -0,0 +1,140 @@
@import "../../../css/static/mixin";
.container {
margin-top: .25em;
display: flex;
flex-direction: row;
justify-content: center;
height: 3em;
background: #454545;
border-radius: .2em;
padding: .2em .3em;
&:not(:last-child) {
margin-bottom: .5em;
}
&.info, &.loading {
.left, .right {
display: flex;
flex-direction: column;
justify-content: center;
}
.right {
margin-left: 1em;
flex-shrink: 0;
flex-grow: 0;
width: 6em;
}
.left {
flex-grow: 1;
flex-shrink: 1;
height: 2.4em;
align-self: center;
min-width: 1em;
max-width: 20em;
line-height: 1.2em;
.loading {
display: block;
}
.joinServer {
flex-shrink: 1;
flex-grow: 0;
min-height: 0;
height: 1em;
overflow: hidden;
}
.channelName {
display: flex;
flex-direction: row;
justify-content: flex-start;
color: #b3b3b3;
font-weight: 700;
max-height: 1em;
.name {
margin-left: .25em;
align-self: center;
@include text-dotdotdot();
}
}
.serverName {
flex-shrink: 0;
color: #b3b3b3;
&.large {
max-height: 2.4em;
overflow: hidden;
font-weight: 700;
}
&.short {
max-height: 1.2em;
@include text-dotdotdot();
}
}
}
}
&.error {
flex-direction: column;
.containerError {
height: 2.4em;
color: #cf1717;
display: flex;
flex-direction: column;
justify-content: stretch;
&.noTitle {
justify-content: center;
.title {
display: none;
}
.message {
text-align: center;
}
}
.title {
flex-shrink: 1;
flex-grow: 0;
min-height: 0;
overflow: hidden;
}
.message {
flex-shrink: 0;
font-weight: 700;
max-height: 2.4em;
overflow: hidden;
line-height: 1.2em;
}
}
}
}

View File

@ -0,0 +1,228 @@
import * as React from "react";
import {IpcInviteInfo, IpcInviteInfoLoaded} from "tc-shared/text/bbcode/InviteDefinitions";
import {ChannelMessage, getIpcInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC";
import * as loader from "tc-loader";
import {AppParameters} from "tc-shared/settings";
import {useEffect, useState} from "react";
import _ = require("lodash");
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {Button} from "tc-shared/ui/react-elements/Button";
import {SimpleUrlRenderer} from "tc-shared/text/bbcode/url";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
const cssStyle = require("./InviteRenderer.scss");
const kInviteUrlRegex = /^(https:\/\/)?(teaspeak.de\/|join.teaspeak.de\/(invite\/)?)([a-zA-Z0-9]{4})$/gm;
export function isInviteLink(url: string) : boolean {
kInviteUrlRegex.lastIndex = 0;
return !!url.match(kInviteUrlRegex);
}
type LocalInviteInfo = IpcInviteInfo | { status: "loading" };
type InviteCacheEntry = { status: LocalInviteInfo, timeout: number };
const localInviteCache: { [key: string]: InviteCacheEntry } = {};
const localInviteCallbacks: { [key: string]: (() => void)[] } = {};
const useInviteLink = (linkId: string): LocalInviteInfo => {
if(!localInviteCache[linkId]) {
localInviteCache[linkId] = { status: { status: "loading" }, timeout: setTimeout(() => delete localInviteCache[linkId], 60 * 1000) };
ipcChannel?.sendMessage("query", { linkId });
}
const [ value, setValue ] = useState(localInviteCache[linkId].status);
useEffect(() => {
if(typeof localInviteCache[linkId]?.status === "undefined") {
return;
}
if(!_.isEqual(value, localInviteCache[linkId].status)) {
setValue(localInviteCache[linkId].status);
}
const callback = () => setValue(localInviteCache[linkId].status);
(localInviteCallbacks[linkId] || (localInviteCallbacks[linkId] = [])).push(callback);
return () => localInviteCallbacks[linkId]?.remove(callback);
}, [linkId]);
return value;
}
const LoadedInviteRenderer = React.memo((props: { info: IpcInviteInfoLoaded }) => {
let joinButton = (
<div className={cssStyle.right}>
<Button
color={"green"}
type={"small"}
onClick={() => {
ipcChannel?.sendMessage("connect", {
connectParameters: props.info.connectParameters,
serverAddress: props.info.serverAddress,
serverUniqueId: props.info.serverUniqueId,
});
}}
>
<Translatable>Join Now!</Translatable>
</Button>
</div>
);
const [, setRevision ] = useState(0);
useEffect(() => {
if(props.info.expireTimestamp === 0) {
return;
}
const timeout = props.info.expireTimestamp - (Date.now() / 1000);
if(timeout <= 0) {
return;
}
const timeoutId = setTimeout(() => setRevision(Date.now()));
return () => clearTimeout(timeoutId);
});
if(props.info.expireTimestamp > 0 && Date.now() / 1000 >= props.info.expireTimestamp) {
return (
<InviteErrorRenderer noTitle={true} key={"expired"}>
<Translatable>Link expired</Translatable>
</InviteErrorRenderer>
);
}
if(props.info.channelName) {
return (
<div className={cssStyle.container + " " + cssStyle.info} key={"with-channel"}>
<div className={cssStyle.left}>
<div className={cssStyle.channelName} title={props.info.channelName}>
<ClientIconRenderer icon={ClientIcon.ChannelGreenSubscribed} />
<div className={cssStyle.name}>{props.info.channelName}</div>
</div>
<div className={cssStyle.serverName + " " + cssStyle.short} title={props.info.serverName}>{props.info.serverName}</div>
</div>
{joinButton}
</div>
);
} else {
return (
<div className={cssStyle.container + " " + cssStyle.info} key={"without-channel"}>
<div className={cssStyle.left}>
<div className={cssStyle.joinServer}><Translatable>Join server</Translatable></div>
<div className={cssStyle.serverName + " " + cssStyle.large} title={props.info.serverName}>{props.info.serverName}</div>
</div>
{joinButton}
</div>
);
}
});
const InviteErrorRenderer = (props: { children, noTitle?: boolean }) => {
return (
<div className={cssStyle.container + " " + cssStyle.error}>
<div className={cssStyle.containerError + " " + (props.noTitle ? cssStyle.noTitle : "")}>
<div className={cssStyle.title}>
<Translatable>Failed to load invite key:</Translatable>
</div>
<div className={cssStyle.message}>
{props.children}
</div>
</div>
</div>
);
}
const InviteLoadingRenderer = () => {
return (
<div className={cssStyle.container + " " + cssStyle.loading}>
<div className={cssStyle.left}>
<div className={cssStyle.loading}>
<Translatable>Loading,<br /> please wait</Translatable> <LoadingDots />
</div>
</div>
<div className={cssStyle.right}>
<Button
color={"green"}
type={"small"}
disabled={true}
>
<Translatable>Join now!</Translatable>
</Button>
</div>
</div>
);
}
export const InviteLinkRenderer = (props: { url: string, handlerId: string }) => {
kInviteUrlRegex.lastIndex = 0;
const inviteLinkId = kInviteUrlRegex.exec(props.url)[4];
const linkInfo = useInviteLink(inviteLinkId);
let body;
switch (linkInfo.status) {
case "success":
body = <LoadedInviteRenderer info={linkInfo} key={"loaded"} />;
break;
case "loading":
body = <InviteLoadingRenderer key={"loading"} />;
break;
case "error":
body = (
<InviteErrorRenderer key={"error"}>
{linkInfo.message}
</InviteErrorRenderer>
);
break;
case "expired":
body = (
<InviteErrorRenderer key={"expired"} noTitle={true}>
<Translatable>Invite link expired</Translatable>
</InviteErrorRenderer>
);
break;
case "not-found":
body = (
<InviteErrorRenderer key={"expired"} noTitle={true}>
<Translatable>Unknown invite link</Translatable>
</InviteErrorRenderer>
);
break;
}
return (
<React.Fragment>
<SimpleUrlRenderer target={props.url}>{props.url}</SimpleUrlRenderer>
{body}
</React.Fragment>
);
}
let ipcChannel: IPCChannel;
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "Invite controller init",
function: async () => {
ipcChannel = getIpcInstance().createChannel(AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, undefined), "invite-info");
ipcChannel.messageHandler = handleIpcMessage;
},
priority: 10
});
function handleIpcMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
console.error(message);
if(message.type === "query-result") {
if(!localInviteCache[message.data.linkId]) {
return;
}
localInviteCache[message.data.linkId].status = message.data.result;
localInviteCallbacks[message.data.linkId]?.forEach(callback => callback());
}
}

View File

@ -13,11 +13,13 @@ import "./highlight";
import "./youtube";
import "./url";
import "./image";
import {ElementRenderer, Renderer} from "vendor/xbbcode/renderer/base";
import {TextElement} from "vendor/xbbcode/elements";
export let BBCodeHandlerContext: Context<string>;
export const rendererText = new TextRenderer();
export const rendererReact = new ReactRenderer();
export const rendererReact = new ReactRenderer(true);
export const rendererHTML = new HTMLRenderer(rendererReact);
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {

View File

@ -8,6 +8,7 @@ import ReactRenderer from "vendor/xbbcode/renderer/react";
import {rendererReact, rendererText, BBCodeHandlerContext} from "tc-shared/text/bbcode/renderer";
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
import {isYoutubeLink, YoutubeRenderer} from "tc-shared/text/bbcode/youtube";
import {InviteLinkRenderer, isInviteLink} from "tc-shared/text/bbcode/InviteRenderer";
function spawnUrlContextMenu(pageX: number, pageY: number, target: string) {
contextmenu.spawn_context_menu(pageX, pageY, {
@ -35,6 +36,17 @@ function spawnUrlContextMenu(pageX: number, pageY: number, target: string) {
const ClientUrlRegex = /client:\/\/([0-9]+)\/([-A-Za-z0-9+/=]+)~/g;
export const SimpleUrlRenderer = (props: { target: string, children }) => {
return (
<a className={"xbbcode xbbcode-tag-url"} href={props.target} target={"_blank"} onContextMenu={event => {
event.preventDefault();
spawnUrlContextMenu(event.pageX, event.pageY, props.target);
}}>
{props.children}
</a>
);
}
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "XBBCode code tag init",
function: async () => {
@ -65,23 +77,26 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
const clientDatabaseId = parseInt(clientData[1]);
const clientUniqueId = clientDatabaseId[2];
return <ClientTag
key={"er-" + ++reactId}
clientName={rendererText.renderContent(element).join("")}
clientUniqueId={clientUniqueId}
clientDatabaseId={clientDatabaseId > 0 ? clientDatabaseId : undefined}
handlerId={handlerId}
/>;
return (
<ClientTag
key={"er-" + ++reactId}
clientName={rendererText.renderContent(element).join("")}
clientUniqueId={clientUniqueId}
clientDatabaseId={clientDatabaseId > 0 ? clientDatabaseId : undefined}
handlerId={handlerId}
/>
);
}
if(isInviteLink(target)) {
return <InviteLinkRenderer key={"er-" + ++reactId} handlerId={handlerId} url={target} />;
}
}
const body = (
<a key={"er-" + ++reactId} className={"xbbcode xbbcode-tag-url"} href={target} target={"_blank"} onContextMenu={event => {
event.preventDefault();
spawnUrlContextMenu(event.pageX, event.pageY, target);
}}>
<SimpleUrlRenderer key={"er-" + ++reactId} target={target}>
{renderer.renderContent(element)}
</a>
</SimpleUrlRenderer>
);
if(isYoutubeLink(target)) {

View File

@ -693,42 +693,50 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
return ChannelType.TEMPORARY;
}
joinChannel(ignorePasswordFlag?: boolean) {
if(this.channelTree.client.getClient().currentChannel() === this)
return;
async joinChannel(ignorePasswordFlag?: boolean) : Promise<boolean> {
if(this.channelTree.client.getClient().currentChannel() === this) {
return true;
if(this.properties.channel_flag_password === true && !this.cachedPasswordHash && !ignorePasswordFlag) {
this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).then(() => {
this.joinChannel(true);
});
return;
}
this.channelTree.client.serverConnection.send_command("clientmove", {
"clid": this.channelTree.client.getClientId(),
"cid": this.getChannelId(),
"cpw": this.cachedPasswordHash || ""
}).then(() => {
this.channelTree.client.sound.play(Sound.CHANNEL_JOINED);
}).catch(error => {
if(this.properties.channel_flag_password === true && !this.cachedPasswordHash && !ignorePasswordFlag) {
const password = await this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD);
if(typeof password === "undefined") {
/* aborted */
return;
}
}
try {
await this.channelTree.client.serverConnection.send_command("clientmove", {
"clid": this.channelTree.client.getClientId(),
"cid": this.getChannelId(),
"cpw": this.cachedPasswordHash || ""
});
return true;
} catch (error) {
if(error instanceof CommandResult) {
if(error.id == ErrorCode.CHANNEL_INVALID_PASSWORD) { //Invalid password
this.invalidateCachedPassword();
}
}
});
return false;
}
}
async requestChannelPassword(ignorePermission: PermissionType) : Promise<{ hash: string } | undefined> {
if(this.cachedPasswordHash)
if(this.cachedPasswordHash) {
return { hash: this.cachedPasswordHash };
}
if(this.channelTree.client.permissions.neededPermission(ignorePermission).granted(1))
if(this.channelTree.client.permissions.neededPermission(ignorePermission).granted(1)) {
return { hash: "having ignore permission" };
}
const password = await new Promise(resolve => createInputModal(tr("Channel password"), tr("Channel password:"), () => true, resolve).open())
if(typeof(password) !== "string" || !password)
if(typeof(password) !== "string" || !password) {
return;
}
const hash = await hashPassword(password);
this.cachedPasswordHash = hash;
@ -741,7 +749,11 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" });
}
cached_password() { return this.cachedPasswordHash; }
setCachedHashedPassword(passwordHash: string) {
this.cachedPasswordHash = passwordHash;
}
getCachedPasswordHash() { return this.cachedPasswordHash; }
async updateSubscribeMode() {
let shouldBeSubscribed = false;
@ -845,7 +857,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
}
const subscribed = this.isSubscribed();
if (this.properties.channel_flag_password === true && !this.cached_password()) {
if (this.properties.channel_flag_password === true && !this.getCachedPasswordHash()) {
return subscribed ? ClientIcon.ChannelYellowSubscribed : ClientIcon.ChannelYellow;
} else if (!this.properties.channel_flag_maxclients_unlimited && this.clients().length >= this.properties.channel_maxclients) {
return subscribed ? ClientIcon.ChannelRedSubscribed : ClientIcon.ChannelRed;

View File

@ -270,6 +270,19 @@ export class ChannelTree {
return undefined;
}
/**
* Resolve a channel by its path
*/
resolveChannelPath(target: string) : ChannelEntry | undefined {
if(target.match(/^\/[0-9]+$/)) {
const channelId = parseInt(target.substring(1));
return this.findChannel(channelId);
} else {
/* TODO: Resolve the whole channel path */
return undefined;
}
}
find_channel_by_name(name: string, parent?: ChannelEntry, force_parent: boolean = true) : ChannelEntry | undefined {
for(let index = 0; index < this.channels.length; index++)
if(this.channels[index].channelName() == name && (!force_parent || parent == this.channels[index].parent))

View File

@ -182,7 +182,7 @@ class InviteController {
this.targetChannelId = channel.channelId;
this.targetChannelName = channel.channelName();
this.targetChannelPasswordHashed = channel.cached_password();
this.targetChannelPasswordHashed = channel.getCachedPasswordHash();
this.targetChannelPasswordRaw = undefined;
} else if(this.targetChannelId === 0) {
return;
@ -281,7 +281,7 @@ class InviteController {
const inviteLink = result.unwrap();
this.linkAdminToken = inviteLink.adminToken;
this.inviteLinkShort = `https://teaspeak.de/${inviteLink.linkId}`;
this.inviteLinkLong = `https://join.teaspeak.de/invite/${inviteLink.linkId}`;
this.inviteLinkLong = `https://join.teaspeak.de/${inviteLink.linkId}`;
this.inviteLinkExpireDate = this.linkExpiresAfter;
}
}

View File

@ -533,7 +533,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
return {
path: e.info.path,
cid: e.info.channelId,
cpw: e.info.channel?.cached_password(),
cpw: e.info.channel?.getCachedPasswordHash(),
name: e.name
}
})).then(async result => {
@ -647,7 +647,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
//ftcreatedir cid=4 cpw dirname=\/TestDir return_code=1:17
connection.serverConnection.send_command("ftcreatedir", {
cid: path.channelId,
cpw: path.channel.cached_password(),
cpw: path.channel.getCachedPasswordHash(),
dirname: path.path + event.name
}).then(() => {
events.fire("action_create_directory_result", {path: event.path, name: event.name, status: "success"});
@ -709,7 +709,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
channel: info.channelId,
path: info.type === "channel" ? info.path : "",
name: info.type === "channel" ? file.name : "/" + file.name,
channelPassword: info.channel?.cached_password(),
channelPassword: info.channel?.getCachedPasswordHash(),
targetSupplier: targetSupplier
});
transfer.awaitFinished().then(() => {
@ -752,7 +752,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
const fileName = file.name;
const transfer = connection.fileManager.initializeFileUpload({
channel: pathInfo.channelId,
channelPassword: pathInfo.channel?.cached_password(),
channelPassword: pathInfo.channel?.getCachedPasswordHash(),
name: file.name,
path: pathInfo.path,
source: async () => TransferProvider.provider().createBrowserFileSource(file)

View File

@ -8,6 +8,7 @@ import {
PopoutIPCMessage
} from "../../../ui/react-elements/external-modal/IPCMessage";
import {ModalController, ModalEvents, ModalOptions, ModalState} from "../../../ui/react-elements/ModalDefinitions";
import {guid} from "tc-shared/crypto/uid";
export abstract class AbstractExternalModalController extends EventControllerBase<"controller"> implements ModalController {
public readonly modalType: string;
@ -26,7 +27,7 @@ export abstract class AbstractExternalModalController extends EventControllerBas
this.modalEvents = new Registry<ModalEvents>();
this.ipcChannel = ipc.getIpcInstance().createChannel();
this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, "modal-" + guid());
this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this);
this.documentUnloadListener = () => this.destroy();