Skip to content

Commit

Permalink
A/Z keyboard navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
electroly committed Jan 13, 2025
1 parent 29bbbc7 commit 4ae6043
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 9 deletions.
14 changes: 12 additions & 2 deletions src/DomMutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export class DomMutator {
this.state = state;
this.contextMenuManager = contextMenuManager;
this.messageSelector = messageSelector;

// Set up handler for message selection changes
this.state.onSelectedMessageChange((messageId) => {
if (messageId) {
const message = this.state.getMessageInfo(messageId);
if (message) {
this.scrollToMessage(message);
}
}
});
}

private scrollToMessage(message: MessageInfo): void {
Expand Down Expand Up @@ -231,13 +241,13 @@ export class DomMutator {
el.dataset.timestamp = message.timestamp.toString();

el.addEventListener("click", () => {
this.scrollToMessage(message);
this.messageSelector.selectMessage(message.id);
this.state.threadContainer?.focus();
});

el.addEventListener("contextmenu", (event) => {
this.scrollToMessage(message);
this.messageSelector.selectMessage(message.id);
this.state.threadContainer?.focus();
this.contextMenuManager.handleContextMenu(event, message);
});

Expand Down
82 changes: 82 additions & 0 deletions src/MessageSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,91 @@ import { ThreadloafState } from "./ThreadloafState";
export class MessageSelector {
private state: ThreadloafState;
private readonly STYLE_ELEMENT_ID = "threadloaf-message-selection-style";
private currentKeydownHandler: ((event: KeyboardEvent) => void) | null = null;

public constructor(state: ThreadloafState) {
this.state = state;
this.setupChatViewClickHandler();
this.state.onThreadContainerChange((container) => this.handleThreadContainerChange(container));
}

private handleThreadContainerChange(container: HTMLElement | null): void {
// Remove handler from old container if it exists
if (this.currentKeydownHandler && this.state.threadContainer) {
this.state.threadContainer.removeEventListener("keydown", this.currentKeydownHandler, true);
this.state.threadContainer.tabIndex = -1;
}

// Set up handler for new container
if (container) {
this.currentKeydownHandler = (event: KeyboardEvent): void => {
if (event.key === "a" || event.key === "z") {
event.preventDefault();
event.stopPropagation();
this.moveSelection(event.key === "a" ? "up" : "down");
}
};
container.addEventListener("keydown", this.currentKeydownHandler, true);
container.tabIndex = 0;
}
}

private moveSelection(direction: "up" | "down"): void {
if (!this.state.selectedMessageId) {
// If nothing is selected, select the first/last message
const messages = document.querySelectorAll("div.threadloaf-message");
if (messages.length === 0) return;

const message = direction === "up" ? messages[messages.length - 1] : messages[0];
const messageId = message.getAttribute("data-msg-id");
if (messageId) this.selectMessage(messageId);
return;
}

// Find the current message element
const currentMessage = document.querySelector(
`div.threadloaf-message[data-msg-id="${this.state.selectedMessageId}"]`,
);
if (!currentMessage) return;

// Get all messages and find current index
const messages = Array.from(document.querySelectorAll("div.threadloaf-message"));
const currentIndex = messages.indexOf(currentMessage);
if (currentIndex === -1) return;

// Calculate next index
const nextIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= messages.length) return;

// Select the next message
const nextMessageId = messages[nextIndex].getAttribute("data-msg-id");
if (nextMessageId) {
this.selectMessage(nextMessageId);
// Ensure the newly selected message is visible
messages[nextIndex].scrollIntoView({ block: "nearest", behavior: "auto" });
}
}

private setupChatViewClickHandler(): void {
// Attach to the highest stable point in Discord's DOM
document.body.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
if (!target) return;

// Find closest message container by walking up the tree
const messageContainer = target.closest('div[class*="message_"][aria-labelledby*="message-content-"]');
if (!messageContainer) return;

// Extract message ID from aria-labelledby
const ariaLabelledBy = messageContainer.getAttribute("aria-labelledby");
if (!ariaLabelledBy) return;

const match = ariaLabelledBy.match(/message-content-([^-\s]+)/);
if (!match) return;

const messageId = match[1];
this.selectMessage(messageId);
});
}

public selectMessage(messageId: string): void {
Expand Down
3 changes: 3 additions & 0 deletions src/ThreadRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ export class ThreadRenderer {

const allMessages = getAllMessages();

// Update the message map in state
this.state.setMessageInfoMap(allMessages);

// Sort for color grading (newest first)
const colorSortedMessages = [...allMessages].sort((a, b) => b.timestamp - a.timestamp);
const messageColors = new Map<string, string>();
Expand Down
5 changes: 0 additions & 5 deletions src/Threadloaf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export class Threadloaf {
this.setupHeaderObserver();
this.domParser.setupMutationObserver(() => this.threadRenderer.renderThread());
this.setupPolling();
this.setupKeyboardNavigation();

// Find initial thread container and set up initial view
const initialThreadContainer = this.domParser.findThreadContainer();
Expand Down Expand Up @@ -86,8 +85,4 @@ export class Threadloaf {
subtree: true,
});
}

private setupKeyboardNavigation(): void {
// Remove keyboard navigation since we no longer have expanded messages
}
}
48 changes: 46 additions & 2 deletions src/ThreadloafState.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,60 @@
import { MessageInfo } from "./MessageInfo";

/**
* Manages the global state of the Threadloaf extension.
* Maintains references to key DOM elements, observers, and UI state flags
* that need to be accessed across different components of the extension.
*/
export class ThreadloafState {
public appContainer: HTMLElement | null = null;
public threadContainer: HTMLElement | null = null;
private _threadContainer: HTMLElement | null = null;
private threadContainerChangeHandlers: Array<(container: HTMLElement | null) => void> = [];
private _selectedMessageId: string | null = null;
private selectedMessageChangeHandlers: Array<(messageId: string | null) => void> = [];
private messageInfoMap = new Map<string, MessageInfo>();
public observer: MutationObserver | null = null;
public headerObserver: MutationObserver | null = null;
public isThreadViewActive = false;
public isLoadingMore = false;
public newestMessageId: string | null = null;
public pendingScrollToNewest: { shouldExpand: boolean } | null = null;
public selectedMessageId: string | null = null;

public get threadContainer(): HTMLElement | null {
return this._threadContainer;
}

public set threadContainer(container: HTMLElement | null) {
this._threadContainer = container;
this.threadContainerChangeHandlers.forEach((handler) => handler(container));
}

public onThreadContainerChange(handler: (container: HTMLElement | null) => void): void {
this.threadContainerChangeHandlers.push(handler);
}

public get selectedMessageId(): string | null {
return this._selectedMessageId;
}

public set selectedMessageId(messageId: string | null) {
if (this._selectedMessageId === messageId) {
this._selectedMessageId = null;
} else {
this._selectedMessageId = messageId;
}
this.selectedMessageChangeHandlers.forEach((handler) => handler(messageId));
}

public onSelectedMessageChange(handler: (messageId: string | null) => void): void {
this.selectedMessageChangeHandlers.push(handler);
}

public setMessageInfoMap(messages: MessageInfo[]): void {
this.messageInfoMap.clear();
messages.forEach((msg) => this.messageInfoMap.set(msg.id, msg));
}

public getMessageInfo(messageId: string): MessageInfo | undefined {
return this.messageInfoMap.get(messageId);
}
}

0 comments on commit 4ae6043

Please sign in to comment.