diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index c0eb431..658fead 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -18,7 +18,11 @@ import { import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; -import { DatabaseService } from './infrastructure/persistence'; +import { + DatabaseService, + loadGeneralSettingsFromStorage, + loadLastViewedChatFromStorage +} from './infrastructure/persistence'; import { DesktopAppUpdateService } from './core/services/desktop-app-update.service'; import { ServerDirectoryFacade } from './domains/server-directory'; import { NotificationsFacade } from './domains/notifications'; @@ -38,8 +42,7 @@ import { RoomsActions } from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { ROOM_URL_PATTERN, - STORAGE_KEY_CURRENT_USER_ID, - STORAGE_KEY_LAST_VISITED_ROUTE + STORAGE_KEY_CURRENT_USER_ID } from './core/constants'; import { ThemeNodeDirective, @@ -222,14 +225,16 @@ export class App implements OnInit, OnDestroy { }).catch(() => {}); } } else { - const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE); + const current = this.router.url; + const generalSettings = loadGeneralSettingsFromStorage(); + const lastViewedChat = loadLastViewedChatFromStorage(currentUserId); - if (last && typeof last === 'string') { - const current = this.router.url; - - if (current === '/' || current === '/search') { - this.router.navigate([last], { replaceUrl: true }).catch(() => {}); - } + if ( + generalSettings.reopenLastViewedChat + && lastViewedChat + && (current === '/' || current === '/search') + ) { + this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {}); } } @@ -237,8 +242,6 @@ export class App implements OnInit, OnDestroy { if (evt instanceof NavigationEnd) { const url = evt.urlAfterRedirects || evt.url; - localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url); - const roomMatch = url.match(ROOM_URL_PATTERN); const currentRoomId = roomMatch ? roomMatch[1] : null; diff --git a/toju-app/src/app/core/constants.ts b/toju-app/src/app/core/constants.ts index db4bc8b..ef87c85 100644 --- a/toju-app/src/app/core/constants.ts +++ b/toju-app/src/app/core/constants.ts @@ -1,5 +1,6 @@ export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId'; -export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute'; +export const STORAGE_KEY_GENERAL_SETTINGS = 'metoyou_general_settings'; +export const STORAGE_KEY_LAST_VIEWED_CHAT = 'metoyou_lastViewedChat'; export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings'; export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html index bff815b..eb079a1 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html @@ -37,7 +37,17 @@ } - @for (message of messages(); track message.id) { + @for (message of messages(); track message.id; let index = $index) { + @if (dateSeparatorLabels().get(index); as separatorLabel) { +
+
+ + {{ separatorLabel }} + +
+
+ } + ; + private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + readonly allMessages = input.required(); readonly channelMessages = input.required(); readonly loading = input(false); @@ -83,6 +90,23 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { () => this.channelMessages().length > this.displayLimit() ); + readonly dateSeparatorLabels = computed(() => { + const labels = new Map(); + let previousDayKey: string | null = null; + + this.messages().forEach((message, index) => { + const timestamp = this.getMessageDateTimestamp(message); + const currentDayKey = this.getMessageDayKey(timestamp); + + if (currentDayKey !== previousDayKey) { + labels.set(index, this.dateSeparatorFormatter.format(new Date(timestamp))); + previousDayKey = currentDayKey; + } + }); + + return labels; + }); + private initialScrollObserver: MutationObserver | null = null; private initialScrollTimer: ReturnType | null = null; private boundOnImageLoad: (() => void) | null = null; @@ -342,6 +366,16 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { } } + private getMessageDateTimestamp(message: Message): number { + return message.timestamp || getMessageTimestamp(message); + } + + private getMessageDayKey(timestamp: number): string { + const date = new Date(timestamp); + + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + } + private scrollToBottomSmooth(): void { const element = this.messagesContainer?.nativeElement; diff --git a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html index bd78089..f52b7e7 100644 --- a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html @@ -9,6 +9,29 @@
+
+
+
+

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(