Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions source/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,19 @@ export class InstagramClient extends EventEmitter {
return this.realtimeStatus;
}

public async sendTypingIndicator(
threadId: string,
isTyping: boolean,
): Promise<void> {
this.logger.debug(
`Sending typing indicator: threadId=${threadId}, isTyping=${isTyping}`,
);
await this.realtime?.direct?.indicateActivity({
threadId: threadId,
isActive: isTyping,
});
}

public async getCurrentUser(): Promise<User | undefined> {
try {
const user = await this.ig.user.info(this.ig.state.cookieUserId);
Expand All @@ -511,6 +524,10 @@ export class InstagramClient extends EventEmitter {
}
}

public getCurrentUserId(): string {
return this.ig.state.cookieUserId;
}

public async getThreads(
loadMore = false,
): Promise<{threads: Thread[]; hasMore: boolean}> {
Expand Down Expand Up @@ -1207,6 +1224,17 @@ export class InstagramClient extends EventEmitter {
this.setRealtimeStatus('disconnected');
});

// Listen to ALL events for debugging
const originalEmit = this.realtime.emit.bind(this.realtime);
(this.realtime as any).emit = (event: string, ...args: any[]) => {
if (event !== 'error' && event !== 'close' && event !== 'message') {
this.logger.info(
`[InstagramClient] 🎯 Realtime emitted event: ${event}`,
);
}
return originalEmit(event as any, ...args);
};

this.realtime.on('message', (wrapper: any) => {
this.logger.debug(`Received MQTT "message": ${JSON.stringify(wrapper)}`);
// Handle reaction events
Expand Down Expand Up @@ -1254,6 +1282,43 @@ export class InstagramClient extends EventEmitter {
}
});

// Activity indicator updates - listen to 'direct' event
this.realtime.on('direct', (data: any) => {
try {
// Check if this is an activity indicator update
if (data.op === 'add' && data.path?.includes('activity_indicator')) {
const threadIdMatch = /\/direct_v2\/threads\/([^/]+)\//.exec(
data.path,
);
const threadId = threadIdMatch?.[1];

this.logger.info(
'*** [InstagramClient] Activity indicator found! ***',
);
this.logger.info(`[InstagramClient] threadId: ${threadId}`);
this.logger.info(
`[InstagramClient] value: ${JSON.stringify(data.value)}`,
);

if (threadId && data.value) {
const senderId = data.value.sender_id?.toString();
const activityStatus = data.value.activity_status;
this.logger.info(
`[InstagramClient] threadId=${threadId}, senderId=${senderId}, status=${activityStatus}`,
);
this.emit('activityIndicator', {
threadId,
senderId,
activityStatus,
timestamp: data.value.timestamp,
});
}
}
} catch (error) {
this.logger.error('Failed to process direct event', error);
}
});

await this.realtime.connect({
graphQlSubs: [
GraphQLSubscriptions.getAppPresenceSubscription(),
Expand Down
39 changes: 39 additions & 0 deletions source/ui/components/input-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {AutocompleteView} from './autocomplete-view.js';
type InputBoxProperties = {
readonly onSend: (message: string) => void;
readonly isDisabled?: boolean;
readonly onTypingChange?: (isTyping: boolean) => void;
};

type AutocompleteState = {
Expand All @@ -33,19 +34,27 @@ const initialAutocompleteState: AutocompleteState = {
export default function InputBox({
onSend,
isDisabled = false,
onTypingChange,
}: InputBoxProperties) {
const [message, setMessage] = useState('');
const [autocomplete, setAutocomplete] = useState<AutocompleteState>(
initialAutocompleteState,
);

const typingTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);

// By changing the key, we force the TextInput to re-mount, which resets its internal state (including cursor position after autocomplete selection)
const [inputKey, setInputKey] = useState(0);

const handleSubmit = (value: string) => {
if (value.trim()) {
onSend(value.trim());
setMessage('');

onTypingChange?.(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
}

setAutocomplete(initialAutocompleteState); // Reset on submit
Expand Down Expand Up @@ -102,6 +111,26 @@ export default function InputBox({
const handleInputChange = (value: string) => {
setMessage(value);

// Typing indicator logic
if (onTypingChange) {
if (value.trim().length > 0) {
onTypingChange(true);

if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}

typingTimeoutRef.current = setTimeout(() => {
onTypingChange(false);
}, 3000); // 3 seconds of inactivity
} else {
onTypingChange(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
}
}

const commandMatch = /^:(\w*)$/.exec(value);
// Updated regex to match # at the beginning of string or after whitespace
// modified regex to match files which contains spaces.
Expand Down Expand Up @@ -140,6 +169,16 @@ export default function InputBox({
}
};

useEffect(() => {
return () => {
// Cleanup typing timeout on unmount
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
onTypingChange?.(false);
};
}, [onTypingChange]);

// This single useInput hook handles all key presses, creating a clear priority
useInput((_input, key) => {
if (isDisabled) {
Expand Down
39 changes: 39 additions & 0 deletions source/ui/components/typing-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React, {useState, useEffect} from 'react';
import {Text, Box} from 'ink';

export const TypingIndicator = ({isTyping}: {isTyping: boolean}) => {
const [frame, setFrame] = useState(0);

useEffect(() => {
if (!isTyping) return;

const interval = setInterval(() => {
setFrame(prev => (prev + 1) % 3);
}, 700);

return () => clearInterval(interval);
}, [isTyping]);

if (!isTyping) return null;

// Each dot gets brighter in sequence
const getDotColor = (dotIndex: number) => {
const activeIndex = frame;
if (dotIndex === activeIndex) return 'cyan'; // Bright
return 'gray'; // Dim
};

return (
<Box marginLeft={1} flexDirection="row">
<Text color={getDotColor(0)} bold>
●{' '}
</Text>
<Text color={getDotColor(1)} bold>
●{' '}
</Text>
<Text color={getDotColor(2)} bold>
</Text>
</Box>
);
};
80 changes: 80 additions & 0 deletions source/ui/views/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import SearchInput from '../components/search-input.js';
import SinglePostView from '../components/single-post-view.js';
import {useImageProtocol} from '../hooks/use-image-protocol.js';
import {updateThreadByMessage} from '../../utils/thread-utils.js';
import {TypingIndicator} from '../components/typing-indicator.js';

type SearchMode = 'username' | 'title' | undefined;

Expand Down Expand Up @@ -59,6 +60,7 @@ export default function ChatView({
undefined,
);

const [isRecipientTyping, setIsRecipientTyping] = useState(false);
const [searchMode, setSearchMode] = useState<SearchMode>(initialSearchMode);
const [searchQuery, setSearchQuery] = useState(initialSearchQuery ?? '');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
Expand Down Expand Up @@ -345,6 +347,9 @@ export default function ChatView({
const handleMessage = async (message: Message) => {
// for current thread, append to message list and handle view changes
if (message.threadId === chatState.currentThread?.id) {
// Clear typing indicator when message arrives
setIsRecipientTyping(false);

setChatState(prev => ({
...prev,
messages: [...prev.messages, message],
Expand Down Expand Up @@ -523,6 +528,63 @@ export default function ChatView({
};
}, [client, realtimeStatus]);

useEffect(() => {
if (!client || !chatState.currentThread) return;

let typingTimeout: NodeJS.Timeout | undefined;

const handleActivityIndicator = (activity: {threadId: string; senderId?: string; activityStatus?: number; activity_status?: number}) => {
// Sadece aktif thread için ve başkasının typing indicator'ı için
if (activity.threadId === chatState.currentThread?.id) {
// activity_status: 1 = typing, 0 = stopped
// Support both camelCase and snake_case properties
const activityStatus =
activity.activityStatus ?? activity.activity_status;

// Kendi typing indicator'ımızı ignore et
const senderId = activity.senderId?.toString();
const currentUserId = client.getCurrentUserId();

if (senderId && senderId === currentUserId) {
return;
}

// Clear existing timeout
if (typingTimeout) {
clearTimeout(typingTimeout);
typingTimeout = undefined;
}

if (activityStatus === 1) {
// User started typing - show immediately
setIsRecipientTyping(true);
// Auto-clear after 22 seconds if no new event comes
typingTimeout = setTimeout(() => {
setIsRecipientTyping(false);
typingTimeout = undefined;
}, 22000);
} else {
// User stopped typing - debounce for 1.5 seconds before hiding
// This prevents flashing when Instagram sends rapid 1->0->1 events
typingTimeout = setTimeout(() => {
setIsRecipientTyping(false);
typingTimeout = undefined;
}, 1500);
}
}
};

client.on('activityIndicator', handleActivityIndicator);

return () => {
if (typingTimeout) {
clearTimeout(typingTimeout);
}
setIsRecipientTyping(false);
client.off('activityIndicator', handleActivityIndicator);
};
}, [client, chatState.currentThread]);

useInput((input, key) => {
if (viewingPost) {
return;
Expand Down Expand Up @@ -686,6 +748,17 @@ export default function ChatView({
return;
};

const handleTypingChange = useCallback(
async (isTyping: boolean) => {
if (!client || !chatState.currentThread) return;

try {
await client.sendTypingIndicator(chatState.currentThread.id, isTyping);
} catch (error) {}
},
[client, chatState.currentThread],
);

const handleOnScrollToBottom = () => {
setSystemMessage('Scrolled to bottom');
};
Expand Down Expand Up @@ -810,6 +883,12 @@ export default function ChatView({
<Text dimColor>Seen just now</Text>
</Box>
)}
<TypingIndicator isTyping={isRecipientTyping} />
{realtimeStatus !== 'connected' && currentView === 'chat' && (
<Box paddingX={1}>
<Text color="yellow">⚠️ Realtime: {realtimeStatus}</Text>
</Box>
)}
<Box flexDirection="column" flexShrink={0}>
{systemMessage && (
<Box marginTop={1}>
Expand All @@ -819,6 +898,7 @@ export default function ChatView({
<InputBox
isDisabled={chatState.isSelectionMode}
onSend={handleSendMessage}
onTypingChange={handleTypingChange}
/>
</Box>
</Box>
Expand Down
Loading