Skip to content

Commit 716d89d

Browse files
author
code4bones
committed
Animate chat jump control and restore last chat
1 parent 7950083 commit 716d89d

10 files changed

Lines changed: 140 additions & 21 deletions

File tree

‎pwa/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pwa/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@code4bones/branch-pwa",
3-
"version": "0.1.0-beta.73",
3+
"version": "0.1.0-beta.74",
44
"private": true,
55
"type": "module",
66
"scripts": {

‎pwa/public/pwa.css‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,16 @@ body {
783783
border-color: #0a4354 !important;
784784
font-size: 20px;
785785
box-shadow: 0 4px 12px rgb(0 0 0 / 28%);
786+
opacity: 0;
787+
pointer-events: none;
788+
transform: scale(0.72);
789+
transition: opacity 160ms ease-out, transform 160ms ease-out;
790+
}
791+
792+
.pwa-chat-latest-button.is-visible {
793+
opacity: 1;
794+
pointer-events: auto;
795+
transform: scale(1);
786796
}
787797

788798
.pwa-chat-latest-button:hover,
@@ -791,6 +801,12 @@ body {
791801
border-color: #125b70 !important;
792802
}
793803

804+
@media (prefers-reduced-motion: reduce) {
805+
.pwa-chat-latest-button {
806+
transition: none;
807+
}
808+
}
809+
794810
.pwa-chat-error {
795811
padding: 8px 16px;
796812
color: #eb7770;

‎pwa/src/app/App.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import { DiscoveryPage } from "../pages/DiscoveryPage.js";
1515
import { OnboardingPage } from "../pages/OnboardingPage.js";
1616
import { MessageRequestsPage } from "../pages/MessageRequestsPage.js";
1717
import { SettingsPage } from "../pages/SettingsPage.js";
18-
import { useThemeControls } from "../state/hooks.js";
18+
import { useContacts, useThemeControls } from "../state/hooks.js";
1919
import { AppStoreProvider, useAppStoreApi } from "../state/StoreProvider.js";
2020
import { useConversationsBootstrap } from "../storage/use-conversations-bootstrap.js";
21+
import { chatPath } from "./paths.js";
2122

2223
export function PwaApp(): React.JSX.Element {
2324
return (
@@ -93,7 +94,7 @@ function ThemedApp(): React.JSX.Element {
9394
)}
9495
>
9596
<Route index element={<Navigate replace to="chats" />} />
96-
<Route path="chats" element={<EmptyChatPane />} />
97+
<Route path="chats" element={<LastOpenedChatOrEmptyPane />} />
9798
<Route path="chats/:contactId" element={<ChatPage />} />
9899
<Route path="requests" element={<MessageRequestsPage />} />
99100
<Route path="settings" element={<SettingsPage />} />
@@ -104,3 +105,13 @@ function ThemedApp(): React.JSX.Element {
104105
</ConfigProvider>
105106
);
106107
}
108+
109+
function LastOpenedChatOrEmptyPane(): React.JSX.Element {
110+
const { contacts, selectedContactId } = useContacts();
111+
const selectedContact = selectedContactId === null
112+
? null
113+
: contacts.find((contact) => contact.contactId === selectedContactId) ?? null;
114+
return selectedContact === null
115+
? <EmptyChatPane />
116+
: <Navigate replace to={chatPath(selectedContact.contactId)} />;
117+
}

‎pwa/src/app/MessageLog.tsx‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,19 +88,19 @@ export function MessageLog({ contactId, messages, emptyDescription, onIncomingMe
8888
))
8989
)}
9090
</div>
91-
{showScrollToBottom && (
92-
<Tooltip title="Scroll to bottom">
93-
<Button
94-
aria-label="Scroll to bottom"
95-
className="pwa-chat-latest-button"
96-
icon={<VerticalAlignBottomOutlined />}
97-
onClick={scrollToLatest}
98-
shape="circle"
99-
size="middle"
100-
type="primary"
101-
/>
102-
</Tooltip>
103-
)}
91+
<Tooltip title="Scroll to bottom">
92+
<Button
93+
aria-hidden={!showScrollToBottom}
94+
aria-label="Scroll to bottom"
95+
className={`pwa-chat-latest-button${showScrollToBottom ? " is-visible" : ""}`}
96+
icon={<VerticalAlignBottomOutlined />}
97+
onClick={scrollToLatest}
98+
shape="circle"
99+
size="middle"
100+
tabIndex={showScrollToBottom ? 0 : -1}
101+
type="primary"
102+
/>
103+
</Tooltip>
104104
</div>
105105
);
106106
}

‎pwa/src/pages/ChatPage.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export function ChatPage(): React.JSX.Element {
4545
const inboundAttachmentOffer = useInboundAttachmentOffer(contact?.peerId ?? null);
4646
const completedAttachment = useCompletedAttachment(contact?.peerId ?? null);
4747

48+
useEffect(() => {
49+
if (contact !== null) {
50+
contacts.selectContact(contact.contactId);
51+
}
52+
}, [contact?.contactId, contacts.selectContact]);
53+
4854
useEffect(() => {
4955
const peerId = contact?.peerId ?? null;
5056
if (peerId === null) {

‎pwa/src/state/slices/contacts-slice.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AppStore } from "../store.js";
44
import { deleteStoredContact, saveStoredContact } from "../../storage/contacts-store.js";
55
import { deleteStoredMessagesForContact } from "../../storage/messages-store.js";
66
import { deleteStoredReadState } from "../../storage/read-state-store.js";
7+
import { clearStoredLastOpenedChat, saveStoredLastOpenedChat } from "../../storage/last-opened-chat-store.js";
78

89
export interface ContactSummary {
910
readonly contactId: string;
@@ -43,6 +44,7 @@ export const createContactsSlice: StateCreator<AppStore, [], [], ContactsSlice>
4344
if (peerId !== null) {
4445
get().clearCompletedAttachmentProjection(peerId);
4546
}
47+
const wasSelected = get().selectedContactId === contactId;
4648
set((state) => {
4749
const { [contactId]: removedMessages, ...messagesByContactId } = state.messagesByContactId;
4850
const { [contactId]: removedReadState, ...lastReadAtByContactId } = state.lastReadAtByContactId;
@@ -66,6 +68,18 @@ export const createContactsSlice: StateCreator<AppStore, [], [], ContactsSlice>
6668
// Best-effort persistence; the contact and local conversation are
6769
// already absent from this session even when IndexedDB is unavailable.
6870
});
71+
if (wasSelected) {
72+
void clearStoredLastOpenedChat().catch(() => {
73+
// Best-effort local preference cleanup follows the local removal.
74+
});
75+
}
6976
},
70-
selectContact: (selectedContactId) => { set({ selectedContactId }); }
77+
selectContact: (selectedContactId) => {
78+
set({ selectedContactId });
79+
if (selectedContactId !== null) {
80+
void saveStoredLastOpenedChat(selectedContactId).catch(() => {
81+
// The current local selection remains usable when IndexedDB is unavailable.
82+
});
83+
}
84+
}
7185
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { RECEIPT_POLICY_STORE, openDatabase } from "./database.js";
2+
3+
const lastOpenedChatKey = "last_opened_chat";
4+
5+
// This is device-local UI state only. It identifies an already-local contact;
6+
// it is neither protocol state nor information a relay can read or restore.
7+
export async function loadStoredLastOpenedChat(): Promise<string | null> {
8+
const db = await openDatabase();
9+
try {
10+
return await new Promise<string | null>((resolve, reject) => {
11+
const request = db.transaction(RECEIPT_POLICY_STORE, "readonly").objectStore(RECEIPT_POLICY_STORE).get(lastOpenedChatKey);
12+
request.onsuccess = () => {
13+
const contactId: unknown = request.result;
14+
resolve(typeof contactId === "string" && contactId.length > 0 && contactId.length <= 128 ? contactId : null);
15+
};
16+
request.onerror = () => { reject(request.error ?? new Error("failed to read last opened chat")); };
17+
});
18+
} finally { db.close(); }
19+
}
20+
21+
export async function saveStoredLastOpenedChat(contactId: string): Promise<void> {
22+
if (contactId.length === 0 || contactId.length > 128) {
23+
return;
24+
}
25+
const db = await openDatabase();
26+
try {
27+
await new Promise<void>((resolve, reject) => {
28+
const transaction = db.transaction(RECEIPT_POLICY_STORE, "readwrite");
29+
transaction.objectStore(RECEIPT_POLICY_STORE).put(contactId, lastOpenedChatKey);
30+
transaction.oncomplete = () => { resolve(); };
31+
transaction.onerror = () => { reject(transaction.error ?? new Error("failed to save last opened chat")); };
32+
});
33+
} finally { db.close(); }
34+
}
35+
36+
export async function clearStoredLastOpenedChat(): Promise<void> {
37+
const db = await openDatabase();
38+
try {
39+
await new Promise<void>((resolve, reject) => {
40+
const transaction = db.transaction(RECEIPT_POLICY_STORE, "readwrite");
41+
transaction.objectStore(RECEIPT_POLICY_STORE).delete(lastOpenedChatKey);
42+
transaction.oncomplete = () => { resolve(); };
43+
transaction.onerror = () => { reject(transaction.error ?? new Error("failed to clear last opened chat")); };
44+
});
45+
} finally { db.close(); }
46+
}

‎pwa/src/storage/use-conversations-bootstrap.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { loadStoredReadState } from "./read-state-store.js";
88
import { loadStoredReadReceiptPolicy } from "./receipt-policy-store.js";
99
import { loadStoredContactDiscoveries } from "./contact-discovery-store.js";
1010
import { loadStoredContactDiscoveryPolicy } from "./contact-discovery-policy-store.js";
11+
import { clearStoredLastOpenedChat, loadStoredLastOpenedChat } from "./last-opened-chat-store.js";
1112
import { groupMessagesByContactId } from "../state/slices/conversations-slice.js";
1213
import { useAppStoreApi } from "../state/StoreProvider.js";
1314

@@ -21,18 +22,26 @@ export function useConversationsBootstrap(): void {
2122
useEffect(() => {
2223
void (async () => {
2324
await removeLegacyDemoState();
24-
const [contacts, messages, readState, messageRequests, sendReadReceipts, contactDiscoveries, allowContactDiscovery] = await Promise.all([
25+
const [contacts, messages, readState, messageRequests, sendReadReceipts, contactDiscoveries, allowContactDiscovery, lastOpenedChatId] = await Promise.all([
2526
loadStoredContacts(),
2627
loadStoredMessages(),
2728
loadStoredReadState(),
2829
loadStoredMessageRequests(),
2930
loadStoredReadReceiptPolicy(),
3031
loadStoredContactDiscoveries(),
31-
loadStoredContactDiscoveryPolicy()
32+
loadStoredContactDiscoveryPolicy(),
33+
loadStoredLastOpenedChat()
3234
]);
35+
const selectedContactId = lastOpenedChatId !== null && contacts.some((contact) => contact.contactId === lastOpenedChatId)
36+
? lastOpenedChatId
37+
: null;
38+
if (lastOpenedChatId !== null && selectedContactId === null) {
39+
void clearStoredLastOpenedChat();
40+
}
3341

3442
storeApi.setState({
3543
contacts,
44+
selectedContactId,
3645
messagesByContactId: groupMessagesByContactId(messages),
3746
lastReadAtByContactId: readState,
3847
incomingMessageRequests: messageRequests,

‎pwa/tests/app-shell.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ const contactsStorePath = resolve(process.cwd(), "src/storage/contacts-store.ts"
9595
const messagesStorePath = resolve(process.cwd(), "src/storage/messages-store.ts");
9696
const readStateStorePath = resolve(process.cwd(), "src/storage/read-state-store.ts");
9797
const receiptPolicyStorePath = resolve(process.cwd(), "src/storage/receipt-policy-store.ts");
98+
const lastOpenedChatStorePath = resolve(process.cwd(), "src/storage/last-opened-chat-store.ts");
9899
const receiptPolicySlicePath = resolve(process.cwd(), "src/state/slices/receipt-policy-slice.ts");
99100
const conversationsBootstrapPath = resolve(process.cwd(), "src/storage/use-conversations-bootstrap.ts");
100101
const contactsSlicePath = resolve(process.cwd(), "src/state/slices/contacts-slice.ts");
@@ -786,6 +787,9 @@ void test("PWA chat log follows the reader only when they are already at the lat
786787
assert.match(messageLog, /setShowScrollToBottom\(!atBottom\)/);
787788
assert.match(messageLog, /Scroll to bottom/);
788789
assert.match(messageLog, /VerticalAlignBottomOutlined/);
790+
assert.match(messageLog, /is-visible/);
791+
assert.match(messageLog, /aria-hidden=\{!showScrollToBottom\}/);
792+
assert.match(messageLog, /tabIndex=\{showScrollToBottom \? 0 : -1\}/);
789793
assert.match(messageLog, /ChatDayDivider/);
790794
assert.match(messageLog, /DeliveryStateIcon/);
791795
assert.match(messageLog, /LoadingOutlined spin/);
@@ -1210,6 +1214,8 @@ void test("contacts, messages, read state, and inbound requests persist through
12101214
const readStateSlice = await readFile(readStateSlicePath, "utf8");
12111215
const hydrationSlice = await readFile(hydrationSlicePath, "utf8");
12121216
const requireIdentity = await readFile(requireIdentityPath, "utf8");
1217+
const lastOpenedChatStore = await readFile(lastOpenedChatStorePath, "utf8");
1218+
const app = await readFile(appPath, "utf8");
12131219

12141220
assert.match(database, /const DATABASE_VERSION = 5/);
12151221
assert.match(database, /IDENTITY_STORE/);
@@ -1228,12 +1234,23 @@ void test("contacts, messages, read state, and inbound requests persist through
12281234
assert.match(bootstrap, /loadStoredReadState/);
12291235
assert.match(bootstrap, /await removeLegacyDemoState\(\)/);
12301236
assert.match(bootstrap, /loadStoredMessageRequests/);
1237+
assert.match(bootstrap, /loadStoredLastOpenedChat/);
1238+
assert.match(bootstrap, /contacts\.some\(\(contact\) => contact\.contactId === lastOpenedChatId\)/);
1239+
assert.match(bootstrap, /clearStoredLastOpenedChat/);
12311240
assert.match(bootstrap, /setConversationsLoaded/);
1241+
assert.match(lastOpenedChatStore, /RECEIPT_POLICY_STORE/);
1242+
assert.match(lastOpenedChatStore, /loadStoredLastOpenedChat/);
1243+
assert.match(lastOpenedChatStore, /saveStoredLastOpenedChat/);
1244+
assert.match(lastOpenedChatStore, /clearStoredLastOpenedChat/);
1245+
assert.match(app, /LastOpenedChatOrEmptyPane/);
1246+
assert.match(app, /chatPath\(selectedContact\.contactId\)/);
12321247
assert.match(contactsSlice, /saveStoredContact/);
12331248
assert.match(contactsSlice, /deleteStoredContact/);
12341249
assert.match(contactsSlice, /forgetContact/);
12351250
assert.match(contactsSlice, /deleteStoredMessagesForContact/);
12361251
assert.match(contactsSlice, /deleteStoredReadState/);
1252+
assert.match(contactsSlice, /saveStoredLastOpenedChat/);
1253+
assert.match(contactsSlice, /clearStoredLastOpenedChat/);
12371254
assert.match(messagesStore, /index\("byContactId"\)\.openCursor\(IDBKeyRange\.only\(contactId\)\)/);
12381255
assert.match(readStateStore, /deleteStoredReadState/);
12391256
assert.match(await readFile(receiptPolicyStorePath, "utf8"), /openDatabase/);

0 commit comments

Comments
 (0)