diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index ac8a3269..e0af56fa 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -30,6 +30,7 @@ import { getGroupDmTargetOrigins, isFederationRelayEnabled, computeFederatedId, + sendTypingRelay, } from '../utils/federationOutbox.js'; import { getOurOrigin } from '../utils/federationAuth.js'; import type { FederationRelayEvent } from '@backspace/shared'; @@ -191,6 +192,9 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs } } + // Relay typing stop to remote peers (fire-and-forget) + sendTypingRelay(dmChannelId, 'dm_typing_stop', message.userId); + for (const member of dmMembers) { // If this member had closed the DM, resurface it first if (member.closed === 1) { diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index 8b04c24d..26dd9e0c 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -5,6 +5,7 @@ import { generateSnowflake } from './snowflake.js'; import crypto from 'node:crypto'; import type { FederationRelayEvent, FederationRelayParticipant, FederationRelayAttachment, DmMessageWithUser, FederationRelayRequest } from '@backspace/shared'; import { getOurOrigin, buildFederationHeaders } from './federationAuth.js'; +import { extractDomain } from '../routes/federation.js'; // ─── Settings Cache ────────────────────────────────────────────────────────── @@ -470,3 +471,63 @@ export async function sendCallRelay( return { ok: false, error: err instanceof Error ? err.message : 'fetch_failed' }; } } + +/** + * Send typing indicator events directly to remote peers (bypasses outbox). + * Fire-and-forget — typing is ephemeral, lost packets are acceptable. + */ +export async function sendTypingRelay( + dmChannelId: string, + eventType: 'dm_typing_start' | 'dm_typing_stop', + userId: string, +): Promise { + if (!isFederationRelayEnabled()) return; + + const db = getDb(); + const participants = getDmParticipants(dmChannelId); + const ourOrigin = getOurOrigin(); + + // Find the typing user's identity + const typingUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + if (!typingUser) return; + + // Get the channel's federatedId for cross-instance identification + const channel = db.select({ federatedId: schema.dmChannels.federatedId }) + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, dmChannelId)) + .get(); + if (!channel?.federatedId) return; + + // Find unique remote peer origins + const remoteOrigins = new Set(); + for (const p of participants) { + const normalized = p.homeInstance.startsWith('http') ? p.homeInstance : `https://${p.homeInstance}`; + if (normalized !== ourOrigin) { + remoteOrigins.add(normalized); + } + } + + if (remoteOrigins.size === 0) return; + + const event: FederationRelayEvent = { + eventType, + contextType: 'dm', + messageId: `typing:${userId}:${Date.now()}`, + federatedId: channel.federatedId, + participants, + encryptionVersion: 0, + timestamp: Date.now(), + typing: { + homeUserId: typingUser.homeUserId || typingUser.id, + homeInstance: typingUser.homeInstance || extractDomain(ourOrigin), + username: typingUser.username ?? '', + }, + }; + + // Fire-and-forget to each remote peer + for (const peerOrigin of remoteOrigins) { + sendCallRelay(peerOrigin, [event]).catch(err => { + console.warn(`[federation] Typing relay to ${peerOrigin} failed:`, err); + }); + } +} diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 7b689ab6..428ef7d5 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -11,7 +11,7 @@ import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; -import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId } from '../utils/federationOutbox.js'; +import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay } from '../utils/federationOutbox.js'; import { getOurOrigin } from '../utils/federationAuth.js'; import { generateFederatedCallToken } from '../routes/livekit.js'; import { config } from '../config.js'; @@ -938,6 +938,9 @@ function handleDmTypingStart(event: Record, userId: string, use } } + // Relay typing indicator to remote peers (fire-and-forget) + sendTypingRelay(dmChannelId, 'dm_typing_start', userId); + const timeout = setTimeout(() => { typingTimeouts.delete(key); }, 5000);