Skip to content
Merged
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
3 changes: 2 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const overrides = new Map([
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 800], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them
["src/features/messages/lib/useRichTextEditor.ts", 560], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up).
["src/features/messages/ui/MessageComposer.tsx", 820], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
Expand Down
14 changes: 14 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export function AppShell() {
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const location = useLocation();
const queryClient = useQueryClient();
const {
Expand Down Expand Up @@ -522,6 +523,10 @@ export function AppShell() {
setIsNewDmOpen(true);
}, []);

const handleOpenCreateChannel = React.useCallback(() => {
setIsCreateChannelOpen(true);
}, []);

React.useLayoutEffect(() => {
if (settingsOpen) {
return;
Expand All @@ -545,6 +550,12 @@ export function AppShell() {
return;
}

if (key === "n" && event.shiftKey) {
event.preventDefault();
handleOpenCreateChannel();
return;
}

if (key === "o" && event.shiftKey) {
event.preventDefault();
handleOpenBrowseChannels();
Expand All @@ -565,6 +576,7 @@ export function AppShell() {
}, [
handleOpenBrowseChannels,
handleOpenNewDm,
handleOpenCreateChannel,
handleOpenSearch,
goHome,
settingsOpen,
Expand Down Expand Up @@ -688,13 +700,15 @@ export function AppShell() {
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isCreateChannelOpen={isCreateChannelOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
workspacesHook.switchWorkspace(id);
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
Expand Down
46 changes: 46 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,50 @@ export const ChannelPane = React.memo(function ChannelPane({
const mainEditTarget = editTarget && !isEditInThread ? editTarget : null;
const threadEditTarget = editTarget && isEditInThread ? editTarget : null;

// ↑-to-edit resolvers. Find the most recent message authored by the current
// user in the relevant scope and enter edit mode via `onEdit`. Editability
// mirrors the action bar's gate (`message.pubkey === currentPubkey`); we
// also skip optimistic `pending` messages, which have no persisted event id
// to target. Both scopes are passed in chronological (oldest→newest) order,
// so we select by newest `createdAt` and break ties toward the later array
// position (`>=`) — `createdAt` is second-granularity, so a reply sent in
// the same second as the message before it must still win. Returns true when
// a target was found so MessageComposer can swallow the ArrowUp.
const findLastOwnEditable = React.useCallback(
(candidates: TimelineMessage[]): TimelineMessage | null => {
if (!onEdit || !currentPubkey) return null;
let best: TimelineMessage | null = null;
for (const message of candidates) {
if (message.pubkey !== currentPubkey || message.pending) continue;
if (!best || message.createdAt >= best.createdAt) {
best = message;
}
}
return best;
},
[onEdit, currentPubkey],
);

const handleEditLastOwnMainMessage = React.useCallback((): boolean => {
const target = findLastOwnEditable(messages);
if (!target || !onEdit) return false;
onEdit(target);
return true;
}, [findLastOwnEditable, messages, onEdit]);

const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
if (!onEdit) return false;
// Thread scope = the open thread head plus its replies, in chronological
// order. The head is oldest, so append it first.
const scope: TimelineMessage[] = [];
if (threadHeadMessage) scope.push(threadHeadMessage);
for (const entry of threadMessages) scope.push(entry.message);
const target = findLastOwnEditable(scope);
if (!target) return false;
onEdit(target);
return true;
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);

const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
Expand Down Expand Up @@ -330,6 +374,7 @@ export const ChannelPane = React.memo(function ChannelPane({
editTarget={mainEditTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditLastOwnMessage={handleEditLastOwnMainMessage}
onEditSave={onEditSave}
onSend={onSendMessage}
profiles={profiles}
Expand Down Expand Up @@ -391,6 +436,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
Expand Down
84 changes: 82 additions & 2 deletions desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ export type RichTextEditorOptions = {
/** Called on plain Enter (submit). Handled inside Tiptap's extension system
* so it fires *before* ProseMirror's default splitBlock behaviour. */
onSubmit?: () => void;
/**
* Called on ArrowUp in an empty composer (Slack parity: edit your last
* message). Handled inside ProseMirror's `editorProps.handleKeyDown` — the
* raw DOM keydown hook that runs before any command/caret logic — so it
* fires deterministically even immediately after a send while the editor
* still holds DOM focus (where the keymap plugin and a wrapper-level
* `onKeyDown` both fail to see the event because the WebView's
* vertical-arrow handling consumes it first). The owner should locate the
* most recent message authored by the current user within this composer's
* scope and enter edit mode. Return `true` if a target was found and edit
* mode was entered, so the keystroke is swallowed; return `false` to let
* ArrowUp fall through to normal caret movement.
*/
onEditLastOwnMessage?: () => boolean;
/** When true, plain Enter is passed through (e.g. to select an autocomplete item). */
isAutocompleteOpen?: React.RefObject<boolean>;
};
Expand All @@ -61,6 +75,7 @@ export function useRichTextEditor({
mentionNames,
channelNames,
onSubmit,
onEditLastOwnMessage,
isAutocompleteOpen,
}: RichTextEditorOptions) {
const onUpdateRef = React.useRef(onUpdate);
Expand All @@ -69,6 +84,9 @@ export function useRichTextEditor({
const onSubmitRef = React.useRef(onSubmit);
onSubmitRef.current = onSubmit;

const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
onEditLastOwnMessageRef.current = onEditLastOwnMessage;

const placeholderRef = React.useRef(placeholder);
placeholderRef.current = placeholder;

Expand Down Expand Up @@ -286,6 +304,44 @@ export function useRichTextEditor({
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none",
"data-testid": "message-input",
},
// ArrowUp in an empty composer → edit your last message (Slack
// parity). Handled here in ProseMirror's own DOM `keydown` hook —
// NOT via `addKeyboardShortcuts` (the keymap plugin) and NOT via a
// wrapper-level React `onKeyDown`.
//
// Why this layer specifically: immediately after a send the editor
// still holds DOM focus and the doc was just cleared. In the app's
// WebView, ProseMirror's keymap/vertical-arrow path does not reliably
// route ArrowUp to our binding in that state — the keystroke is
// effectively swallowed until the user clicks out and back (which is
// exactly the reported bug). `handleKeyDown` is the first, lowest hook
// ProseMirror exposes: it runs on the raw DOM keydown before any
// command/caret logic, fires regardless of selection state, and works
// the same across browser engines. Returning `true` consumes the key.
handleKeyDown: (view, event) => {
if (event.key !== "ArrowUp") return false;
// Respect the same guards as before: no modifiers (let ⌥↑/⇧↑/etc.
// through), autocomplete closed, a handler exists, and the composer
// is empty (never steal the arrow from drafted text or an in-flight
// edit, whose loaded body makes the doc non-empty).
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
return false;
if (isAutocompleteOpen?.current) return false;
const handler = onEditLastOwnMessageRef.current;
if (!handler) return false;
// Emptiness is read straight off the live ProseMirror doc rather
// than a captured `editor` ref — the `editor` instance isn't in
// scope at config time (useEditor deps are `[]`), and the view's
// state is always current. Empty = a single empty textblock with
// no text content (mirrors Tiptap's `editor.isEmpty`).
const { doc } = view.state;
const isEmptyDoc =
doc.childCount <= 1 && doc.textContent.length === 0;
if (!isEmptyDoc) return false;
// Consume only if a target was found and edit mode was entered;
// otherwise let ArrowUp fall through to normal caret movement.
return handler();
},
},
onUpdate: ({ editor: ed }) => {
const markdown = getMarkdownFromEditor(ed);
Expand All @@ -301,9 +357,33 @@ export function useRichTextEditor({
);

// Toggle editable without destroying the editor instance.
//
// When the composer is disabled mid-send (`isSending` flips the `disabled`
// prop true), ProseMirror sets the underlying element `contenteditable=false`
// and the browser BLURS it — focus jumps to `document.body`. When the send
// completes and the editor becomes editable again, focus does NOT return on
// its own. That left the just-emptied composer focus-less, so the very next
// ArrowUp (edit-last-message) never reached the editor's keydown hook and
// did nothing until the user clicked back in. We restore focus here, scoped
// to *this* editor instance (we only refocus if this editor was the one that
// lost focus to the disable), so it can't steal focus from another composer.
const hadFocusBeforeDisableRef = React.useRef(false);
React.useEffect(() => {
if (editor && editor.isEditable !== editable) {
editor.setEditable(editable);
if (!editor || editor.isEditable === editable) return;
if (!editable) {
// About to disable: remember whether we currently hold focus so we know
// whether to restore it when re-enabled.
hadFocusBeforeDisableRef.current = editor.isFocused;
editor.setEditable(false);
} else {
editor.setEditable(true);
// Re-enabled: if we owned focus before the disable blurred us, take it
// back (preserving the current selection — `focus()` with no arg keeps
// the existing selection rather than jumping to the end).
if (hadFocusBeforeDisableRef.current) {
hadFocusBeforeDisableRef.current = false;
editor.commands.focus();
}
}
}, [editor, editable]);

Expand Down
19 changes: 19 additions & 0 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ type MessageComposerProps = {
isSending?: boolean;
onCancelEdit?: () => void;
onCancelReply?: () => void;
/**
* Invoked when the user presses ↑ in an empty composer that is not already
* in edit mode. The owner should locate the most recent message authored by
* the current user within this composer's scope (main timeline, DM, or
* thread) and enter edit mode for it. Return `true` if a target was found
* and edit mode was entered, so the composer can swallow the keystroke;
* return `false` to let the arrow key fall through normally.
*/
onEditLastOwnMessage?: () => boolean;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onSend: (
content: string,
Expand Down Expand Up @@ -92,6 +101,7 @@ export function MessageComposer({
isSending = false,
onCancelEdit,
onCancelReply,
onEditLastOwnMessage,
onEditSave,
onSend,
placeholder,
Expand Down Expand Up @@ -145,12 +155,14 @@ export function MessageComposer({
const isUploadingRef = React.useRef(media.isUploading);
const onSendRef = React.useRef(onSend);
const onEditSaveRef = React.useRef(onEditSave);
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
const editTargetRef = React.useRef(editTarget);
disabledRef.current = disabled;
isSendingRef.current = isSending;
isUploadingRef.current = media.isUploading;
onSendRef.current = onSend;
onEditSaveRef.current = onEditSave;
onEditLastOwnMessageRef.current = onEditLastOwnMessage;
editTargetRef.current = editTarget;

const isAutocompleteOpenRef = React.useRef(false);
Expand Down Expand Up @@ -183,6 +195,13 @@ export function MessageComposer({
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
onSubmit: () => submitMessageRef.current(),
onEditLastOwnMessage: () => {
// Never re-enter edit from an empty edit (e.g. image-only edit whose
// text body is empty) — `editTarget` means we're already editing.
if (editTargetRef.current) return false;
const handler = onEditLastOwnMessageRef.current;
return handler ? handler() : false;
},
isAutocompleteOpen: isAutocompleteOpenRef,
onUpdate: ({ markdown, text }) => {
setContent(markdown);
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type MessageThreadPanelProps = {
onClose: () => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditLastOwnMessage?: () => boolean;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onMarkUnread?: (message: TimelineMessage) => void;
onExpandReplies: (message: TimelineMessage) => void;
Expand Down Expand Up @@ -98,6 +99,7 @@ export function MessageThreadPanel({
onClose,
onDelete,
onEdit,
onEditLastOwnMessage,
onEditSave,
onFollowThread,
onMarkUnread,
Expand Down Expand Up @@ -353,6 +355,7 @@ export function MessageThreadPanel({
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onEditLastOwnMessage={onEditLastOwnMessage}
onEditSave={onEditSave}
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
Expand Down
24 changes: 23 additions & 1 deletion desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ type AppSidebarProps = {
isPresencePending?: boolean;
isNewDmOpen?: boolean;
onNewDmOpenChange?: (open: boolean) => void;
isCreateChannelOpen?: boolean;
onCreateChannelOpenChange?: (open: boolean) => void;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -410,6 +412,8 @@ export function AppSidebar({
isPresencePending,
isNewDmOpen: isNewDmOpenProp,
onNewDmOpenChange,
isCreateChannelOpen: isCreateChannelOpenProp,
onCreateChannelOpenChange,
}: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
Expand All @@ -420,6 +424,17 @@ export function AppSidebar({
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null);

// Allow the create-channel dialog to be opened from outside (e.g. the
// ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift.
// When the external flag flips on, open the "stream" create dialog; the
// close direction is reported back via `onCreateChannelOpenChange` in the
// dialog's `onOpenChange` below.
React.useEffect(() => {
if (isCreateChannelOpenProp) {
setCreateDialogKind("stream");
}
}, [isCreateChannelOpenProp]);
const [collapsedGroups, setCollapsedGroups] = React.useState<
Record<CollapsibleSidebarGroup, boolean>
>({
Expand Down Expand Up @@ -808,7 +823,14 @@ export function AppSidebar({
channelKind={createDialogKind}
isCreating={isCreatingAny}
onOpenChange={(open) => {
if (!open) setCreateDialogKind(null);
if (!open) {
// If a "stream" dialog driven by the external controller is
// closing, report it back so AppShell's open state resets.
if (createDialogKind === "stream") {
onCreateChannelOpenChange?.(false);
}
setCreateDialogKind(null);
}
}}
onCreate={handleCreateFromDialog}
/>
Expand Down
Loading