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
5 changes: 0 additions & 5 deletions .npmrc

This file was deleted.

7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# [1.26.0](https://github.com/rodrigogs/whats-reader/compare/v1.25.1...v1.26.0) (2026-01-05)


### Features

* Add responsive chat header options menu for small screens ([#55](https://github.com/rodrigogs/whats-reader/issues/55)) ([4932bd2](https://github.com/rodrigogs/whats-reader/commit/4932bd21ee0bfd552308e43b41e027e8f20ec6c5)), closes [#56](https://github.com/rodrigogs/whats-reader/issues/56)

## [1.25.1](https://github.com/rodrigogs/whats-reader/compare/v1.25.0...v1.25.1) (2026-01-05)


Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "whats-reader",
"productName": "WhatsApp Backup Reader",
"version": "1.25.1",
"version": "1.26.0",
"description": "A desktop app to read and visualize WhatsApp chat exports",
"license": "AGPL-3.0",
"author": {
Expand Down
217 changes: 150 additions & 67 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ function closeParticipantsModal() {
}
let showPerspectiveDropdown = $state(false);
let perspectiveSearchQuery = $state('');
let showChatOptionsDropdown = $state(false);
let chatOptionsButtonRef = $state<HTMLButtonElement | null>(null);

// Loading chats state - shows placeholder items while importing
interface LoadingChat {
Expand Down Expand Up @@ -389,6 +391,7 @@ function selectPerspective(participant: string | null) {
perspectiveByChat = newMap;
}
showPerspectiveDropdown = false;
showChatOptionsDropdown = false;
perspectiveSearchQuery = '';
}

Expand Down Expand Up @@ -587,6 +590,42 @@ const currentUser = $derived.by(() => {

<!-- Main content -->
{#if appState.selectedChat}
{#snippet perspectiveSelectorContent()}
<DropdownHeader title={m.perspective_view_as()} />

<DropdownSearch
bind:value={perspectiveSearchQuery}
bind:ref={perspectiveSearchInputRef}
placeholder={m.perspective_search_placeholder()}
/>

<DropdownList>
{#if !perspectiveSearchQuery}
<ListItemButton
active={currentPerspective === null}
onclick={() => selectPerspective(null)}
>
<span class="w-5 text-center">{currentPerspective === null ? '✓' : ''}</span>
<span class="italic">{m.perspective_none()}</span>
</ListItemButton>
{/if}
{#each filteredParticipants as participant}
<ListItemButton
active={currentPerspective === participant}
onclick={() => selectPerspective(participant)}
>
<span class="w-5 text-center">{currentPerspective === participant ? '✓' : ''}</span>
<span class="truncate">{participant}</span>
</ListItemButton>
{/each}
{#if filteredParticipants.length === 0 && perspectiveSearchQuery}
<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">
{m.perspective_no_match({ query: perspectiveSearchQuery })}
</div>
{/if}
</DropdownList>
{/snippet}

<div class="flex-1 flex flex-col overflow-hidden">
<!-- Chat header -->
<div class="h-16 px-4 flex items-center gap-3 bg-[var(--color-whatsapp-dark-green)] text-white shadow-md flex-shrink-0">
Expand Down Expand Up @@ -628,90 +667,134 @@ const currentUser = $derived.by(() => {

<!-- Actions -->
<div class="flex items-center gap-2">
<!-- Perspective selector -->
<div class="relative">
<!-- Small screens: Options menu -->
<div class="md:hidden relative">
<IconButton
bind:ref={perspectiveButtonRef}
bind:ref={chatOptionsButtonRef}
theme="dark"
size="md"
active={!!currentPerspective}
onclick={() => showPerspectiveDropdown = !showPerspectiveDropdown}
title={m.perspective_view_as()}
aria-label={m.perspective_select()}
onclick={() => showChatOptionsDropdown = !showChatOptionsDropdown}
title={m.chat_options()}
aria-label={m.chat_options()}
>
<Icon name="user" size="md" />
<Icon name="dots-vertical" size="md" />
</IconButton>

<Dropdown
anchor={perspectiveButtonRef}
open={showPerspectiveDropdown}
onClose={() => { showPerspectiveDropdown = false; perspectiveSearchQuery = ''; }}
anchor={chatOptionsButtonRef}
open={showChatOptionsDropdown}
onClose={() => {
showChatOptionsDropdown = false;
showPerspectiveDropdown = false;
perspectiveSearchQuery = '';
}}
width="w-56"
placement="bottom-end"
>
<DropdownHeader title={m.perspective_view_as()} />

<DropdownSearch
bind:value={perspectiveSearchQuery}
bind:ref={perspectiveSearchInputRef}
placeholder={m.perspective_search_placeholder()}
/>

<DropdownList>
{#if !perspectiveSearchQuery}
{#if showPerspectiveDropdown}
<!-- Perspective selector view -->
{@render perspectiveSelectorContent()}
{:else}
<!-- Main options menu -->
<DropdownList>
<ListItemButton
active={currentPerspective === null}
onclick={() => selectPerspective(null)}
active={!!currentPerspective}
onclick={() => showPerspectiveDropdown = true}
>
<span class="w-5 text-center">{currentPerspective === null ? '✓' : ''}</span>
<span class="italic">{m.perspective_none()}</span>
<Icon name="user" size="sm" />
<span>{m.perspective_view_as()}</span>
</ListItemButton>
{/if}
{#each filteredParticipants as participant}
<ListItemButton
active={currentPerspective === participant}
onclick={() => selectPerspective(participant)}
active={showMediaGallery}
onclick={() => {
showChatOptionsDropdown = false;
toggleMediaGallery();
}}
>
<span class="w-5 text-center">{currentPerspective === participant ? '✓' : ''}</span>
<span class="truncate">{participant}</span>
<Icon name="image" size="sm" />
<span>{m.media_gallery_title()}</span>
</ListItemButton>
{/each}
{#if filteredParticipants.length === 0 && perspectiveSearchQuery}
<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">
{m.perspective_no_match({ query: perspectiveSearchQuery })}
</div>
{/if}
</DropdownList>
<ListItemButton
active={showBookmarks}
onclick={() => {
showChatOptionsDropdown = false;
toggleBookmarks();
}}
>
<Icon name="bookmark" size="sm" />
<span>{m.bookmarks_title()}</span>
</ListItemButton>
<ListItemButton
onclick={() => {
showChatOptionsDropdown = false;
toggleStats();
}}
>
<Icon name="chart-bar" size="sm" />
<span>{m.stats_view()}</span>
</ListItemButton>
</DropdownList>
{/if}
</Dropdown>
</div>

<IconButton
theme="dark"
size="md"
active={showMediaGallery}
onclick={toggleMediaGallery}
title={m.media_gallery_title()}
aria-label={m.media_gallery_toggle()}
>
<Icon name="image" size="md" filled={showMediaGallery} />
</IconButton>
<IconButton
theme="dark"
size="md"
active={showBookmarks}
onclick={toggleBookmarks}
title={m.bookmarks_title()}
aria-label={m.bookmarks_toggle()}
>
<Icon name="bookmark" size="md" filled={showBookmarks} />
</IconButton>
<IconButton
theme="dark"
size="md"
onclick={toggleStats}
title={m.stats_view()}
aria-label={m.stats_view()}
>
<Icon name="chart-bar" size="md" />
</IconButton>
<!-- Large screens: Individual buttons -->
<div class="hidden md:flex items-center gap-2">
<!-- Perspective selector -->
<div class="relative">
<IconButton
bind:ref={perspectiveButtonRef}
theme="dark"
size="md"
active={!!currentPerspective}
onclick={() => showPerspectiveDropdown = !showPerspectiveDropdown}
title={m.perspective_view_as()}
aria-label={m.perspective_select()}
>
<Icon name="user" size="md" />
</IconButton>

<Dropdown
anchor={perspectiveButtonRef}
open={showPerspectiveDropdown}
onClose={() => { showPerspectiveDropdown = false; perspectiveSearchQuery = ''; }}
>
{@render perspectiveSelectorContent()}
</Dropdown>
</div>

<IconButton
theme="dark"
size="md"
active={showMediaGallery}
onclick={toggleMediaGallery}
title={m.media_gallery_title()}
aria-label={m.media_gallery_toggle()}
>
<Icon name="image" size="md" filled={showMediaGallery} />
</IconButton>
<IconButton
theme="dark"
size="md"
active={showBookmarks}
onclick={toggleBookmarks}
title={m.bookmarks_title()}
aria-label={m.bookmarks_toggle()}
>
<Icon name="bookmark" size="md" filled={showBookmarks} />
</IconButton>
<IconButton
theme="dark"
size="md"
onclick={toggleStats}
title={m.stats_view()}
aria-label={m.stats_view()}
>
<Icon name="chart-bar" size="md" />
</IconButton>
</div>

<!-- Always visible: Locale and Theme -->
<LocaleSwitcher variant="header" />
<IconButton
theme="dark"
Expand Down