+
+
+
+
Reopen last chat on launch
+
Open the same server and text channel the next time MetoYou starts.
+
+
+
+
+
+
{
const input = event.target as HTMLInputElement;
const enabled = !!input.checked;
@@ -99,6 +115,12 @@ export class GeneralSettingsComponent {
} catch {}
}
+ private loadGeneralSettings(): void {
+ const settings = loadGeneralSettingsFromStorage();
+
+ this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
+ }
+
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
this.autoStart.set(snapshot.autoStart);
this.closeToTray.set(snapshot.closeToTray);
diff --git a/toju-app/src/app/infrastructure/persistence/README.md b/toju-app/src/app/infrastructure/persistence/README.md
index 991f3a1..5a1202d 100644
--- a/toju-app/src/app/infrastructure/persistence/README.md
+++ b/toju-app/src/app/infrastructure/persistence/README.md
@@ -6,12 +6,15 @@ Offline-first storage layer that keeps messages, users, rooms, reactions, bans,
```
persistence/
-├── index.ts Barrel (exports DatabaseService)
+├── app-resume.storage.ts localStorage helpers for launch settings and last viewed chat
+├── index.ts Barrel (exports DatabaseService and storage helpers)
├── database.service.ts Platform-agnostic facade
├── browser-database.service.ts IndexedDB backend (web)
└── electron-database.service.ts IPC/SQLite backend (desktop)
```
+`app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite.
+
## Platform routing
```mermaid
diff --git a/toju-app/src/app/infrastructure/persistence/app-resume.storage.ts b/toju-app/src/app/infrastructure/persistence/app-resume.storage.ts
new file mode 100644
index 0000000..bc13a5d
--- /dev/null
+++ b/toju-app/src/app/infrastructure/persistence/app-resume.storage.ts
@@ -0,0 +1,114 @@
+import {
+ STORAGE_KEY_GENERAL_SETTINGS,
+ STORAGE_KEY_LAST_VIEWED_CHAT
+} from '../../core/constants';
+
+export interface GeneralSettings {
+ reopenLastViewedChat: boolean;
+}
+
+export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
+ reopenLastViewedChat: true
+};
+
+export interface LastViewedChatSnapshot {
+ userId: string;
+ roomId: string;
+ channelId: string | null;
+}
+
+export function loadGeneralSettingsFromStorage(): GeneralSettings {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS);
+
+ if (!raw) {
+ return { ...DEFAULT_GENERAL_SETTINGS };
+ }
+
+ return normaliseGeneralSettings(JSON.parse(raw) as Partial);
+ } catch {
+ return { ...DEFAULT_GENERAL_SETTINGS };
+ }
+}
+
+export function saveGeneralSettingsToStorage(patch: Partial): GeneralSettings {
+ const nextSettings = normaliseGeneralSettings({
+ ...loadGeneralSettingsFromStorage(),
+ ...patch
+ });
+
+ try {
+ localStorage.setItem(STORAGE_KEY_GENERAL_SETTINGS, JSON.stringify(nextSettings));
+ } catch {}
+
+ return nextSettings;
+}
+
+export function loadLastViewedChatFromStorage(userId?: string | null): LastViewedChatSnapshot | null {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT);
+
+ if (!raw) {
+ return null;
+ }
+
+ const snapshot = normaliseLastViewedChatSnapshot(JSON.parse(raw) as Partial);
+
+ if (!snapshot) {
+ return null;
+ }
+
+ if (userId && snapshot.userId !== userId) {
+ return null;
+ }
+
+ return snapshot;
+ } catch {
+ return null;
+ }
+}
+
+export function saveLastViewedChatToStorage(snapshot: LastViewedChatSnapshot): void {
+ const normalised = normaliseLastViewedChatSnapshot(snapshot);
+
+ if (!normalised) {
+ return;
+ }
+
+ try {
+ localStorage.setItem(STORAGE_KEY_LAST_VIEWED_CHAT, JSON.stringify(normalised));
+ } catch {}
+}
+
+export function clearLastViewedChatFromStorage(): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY_LAST_VIEWED_CHAT);
+ } catch {}
+}
+
+function normaliseGeneralSettings(raw: Partial): GeneralSettings {
+ return {
+ reopenLastViewedChat:
+ typeof raw.reopenLastViewedChat === 'boolean'
+ ? raw.reopenLastViewedChat
+ : DEFAULT_GENERAL_SETTINGS.reopenLastViewedChat
+ };
+}
+
+function normaliseLastViewedChatSnapshot(raw: Partial): LastViewedChatSnapshot | null {
+ const userId = typeof raw.userId === 'string' ? raw.userId.trim() : '';
+ const roomId = typeof raw.roomId === 'string' ? raw.roomId.trim() : '';
+ const channelId = typeof raw.channelId === 'string'
+ ? raw.channelId.trim() || null
+ : null;
+
+ if (!userId || !roomId) {
+ return null;
+ }
+
+ return {
+ userId,
+ roomId,
+ channelId
+ };
+}
diff --git a/toju-app/src/app/infrastructure/persistence/index.ts b/toju-app/src/app/infrastructure/persistence/index.ts
index cd4e1d2..ceeda66 100644
--- a/toju-app/src/app/infrastructure/persistence/index.ts
+++ b/toju-app/src/app/infrastructure/persistence/index.ts
@@ -1 +1,2 @@
+export * from './app-resume.storage';
export * from './database.service';
diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts
index 14ac561..76b5b88 100644
--- a/toju-app/src/app/store/rooms/rooms.effects.ts
+++ b/toju-app/src/app/store/rooms/rooms.effects.ts
@@ -2,7 +2,10 @@
/* eslint-disable id-length */
/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
import { Injectable, inject } from '@angular/core';
-import { Router } from '@angular/router';
+import {
+ NavigationEnd,
+ Router
+} from '@angular/router';
import {
Actions,
createEffect,
@@ -15,7 +18,8 @@ import {
import {
of,
from,
- EMPTY
+ EMPTY,
+ merge
} from 'rxjs';
import {
map,
@@ -38,7 +42,12 @@ import {
selectSavedRooms
} from './rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
-import { DatabaseService } from '../../infrastructure/persistence';
+import {
+ clearLastViewedChatFromStorage,
+ DatabaseService,
+ loadLastViewedChatFromStorage,
+ saveLastViewedChatToStorage
+} from '../../infrastructure/persistence';
import {
CLIENT_UPDATE_REQUIRED_MESSAGE,
type ServerSourceSelector,
@@ -137,6 +146,19 @@ function resolveRoomChannels(
return undefined;
}
+function resolveTextChannelId(
+ channels: Room['channels'] | undefined,
+ preferredChannelId?: string | null
+): string | null {
+ const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
+
+ if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) {
+ return preferredChannelId;
+ }
+
+ return textChannels[0]?.id ?? null;
+}
+
interface RoomPresenceSignalingMessage {
type: string;
reason?: string;
@@ -183,6 +205,51 @@ export class RoomsEffects {
)
);
+ /** Opens the routed room after login/refresh once saved rooms are available. */
+ syncViewedRoomToRoute$ = createEffect(() =>
+ merge(
+ this.actions$.pipe(
+ ofType(
+ RoomsActions.loadRoomsSuccess,
+ UsersActions.loadCurrentUserSuccess,
+ UsersActions.setCurrentUser
+ )
+ ),
+ this.router.events.pipe(
+ filter((event): event is NavigationEnd => event instanceof NavigationEnd)
+ )
+ ).pipe(
+ withLatestFrom(
+ this.store.select(selectCurrentUser),
+ this.store.select(selectCurrentRoom),
+ this.store.select(selectSavedRooms)
+ ),
+ mergeMap(([
+ , currentUser,
+ currentRoom,
+ savedRooms
+ ]) => {
+ if (!currentUser) {
+ return EMPTY;
+ }
+
+ const roomId = this.extractRoomIdFromUrl(this.router.url);
+
+ if (!roomId || currentRoom?.id === roomId) {
+ return EMPTY;
+ }
+
+ const room = savedRooms.find((savedRoom) => savedRoom.id === roomId) ?? null;
+
+ if (!room) {
+ return EMPTY;
+ }
+
+ return of(RoomsActions.viewServer({ room }));
+ })
+ )
+ );
+
/** Searches the server directory with debounced input. */
searchServers$ = createEffect(() =>
this.actions$.pipe(
@@ -431,6 +498,106 @@ export class RoomsEffects {
{ dispatch: false }
);
+ /** Remembers the viewed room whenever a room becomes active. */
+ persistLastViewedChatOnRoomActivation$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
+ withLatestFrom(this.store.select(selectCurrentUser)),
+ tap(([
+ { room },
+ currentUser
+ ]) => {
+ if (!currentUser) {
+ return;
+ }
+
+ const persisted = loadLastViewedChatFromStorage(currentUser.id);
+ const channelId = persisted?.roomId === room.id
+ ? resolveTextChannelId(room.channels, persisted.channelId)
+ : resolveTextChannelId(room.channels);
+
+ saveLastViewedChatToStorage({
+ userId: currentUser.id,
+ roomId: room.id,
+ channelId
+ });
+ })
+ ),
+ { dispatch: false }
+ );
+
+ /** Remembers the currently selected text channel for the active room. */
+ persistLastViewedChatOnChannelSelection$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(RoomsActions.selectChannel),
+ withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectCurrentUser)),
+ tap(([
+ { channelId },
+ currentRoom,
+ currentUser
+ ]) => {
+ if (!currentRoom || !currentUser) {
+ return;
+ }
+
+ const resolvedChannelId = resolveTextChannelId(currentRoom.channels, channelId);
+
+ if (!resolvedChannelId || resolvedChannelId !== channelId) {
+ return;
+ }
+
+ saveLastViewedChatToStorage({
+ userId: currentUser.id,
+ roomId: currentRoom.id,
+ channelId
+ });
+ })
+ ),
+ { dispatch: false }
+ );
+
+ /** Restores the last viewed text channel once the active room's channels are known. */
+ restoreLastViewedTextChannel$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(
+ RoomsActions.createRoomSuccess,
+ RoomsActions.joinRoomSuccess,
+ RoomsActions.viewServerSuccess,
+ RoomsActions.updateRoom
+ ),
+ withLatestFrom(
+ this.store.select(selectCurrentUser),
+ this.store.select(selectCurrentRoom),
+ this.store.select(selectActiveChannelId)
+ ),
+ mergeMap(([
+ , currentUser,
+ currentRoom,
+ activeChannelId
+ ]) => {
+ if (!currentUser || !currentRoom) {
+ return EMPTY;
+ }
+
+ const persisted = loadLastViewedChatFromStorage(currentUser.id);
+
+ if (!persisted || persisted.roomId !== currentRoom.id) {
+ return EMPTY;
+ }
+
+ const channelId = resolveTextChannelId(currentRoom.channels, persisted.channelId);
+
+ if (!channelId || channelId === activeChannelId) {
+ return EMPTY;
+ }
+
+ return of(RoomsActions.selectChannel({ channelId }));
+ })
+ )
+ );
+
refreshServerOwnedRoomMetadata$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
@@ -648,6 +815,22 @@ export class RoomsEffects {
)
);
+ /** Clears stale resume state when the remembered room is removed locally. */
+ clearLastViewedChatOnRoomRemoval$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(RoomsActions.deleteRoomSuccess, RoomsActions.forgetRoomSuccess),
+ tap(({ roomId }) => {
+ const persisted = loadLastViewedChatFromStorage();
+
+ if (persisted?.roomId === roomId) {
+ clearLastViewedChatFromStorage();
+ }
+ })
+ ),
+ { dispatch: false }
+ );
+
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
updateRoomSettings$ = createEffect(() =>
this.actions$.pipe(