feat: Add chat seperator and restore last viewed chat on restart

This commit is contained in:
2026-04-02 00:47:44 +02:00
parent bbb6deb0a2
commit 5d7e045764
10 changed files with 412 additions and 18 deletions

View File

@@ -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;

View File

@@ -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';

View File

@@ -37,7 +37,17 @@
</div>
}
@for (message of messages(); track message.id) {
@for (message of messages(); track message.id; let index = $index) {
@if (dateSeparatorLabels().get(index); as separatorLabel) {
<div class="flex items-center gap-3 py-1">
<div class="h-px flex-1 bg-border"></div>
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
{{ separatorLabel }}
</span>
<div class="h-px flex-1 bg-border"></div>
</div>
}
<app-chat-message-item
[message]="message"
[repliedMessage]="findRepliedMessage(message.replyToId)"

View File

@@ -13,6 +13,7 @@ import {
signal
} from '@angular/core';
import { Attachment } from '../../../../../attachment';
import { getMessageTimestamp } from '../../../../domain/message.rules';
import { Message } from '../../../../../../shared-kernel';
import {
ChatMessageDeleteEvent,
@@ -45,6 +46,12 @@ declare global {
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
readonly allMessages = input.required<Message[]>();
readonly channelMessages = input.required<Message[]>();
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<number, string>();
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<typeof setTimeout> | 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;

View File

@@ -9,6 +9,29 @@
</div>
<div class="space-y-3">
<div class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Reopen last chat on launch</p>
<p class="text-xs text-muted-foreground">Open the same server and text channel the next time MetoYou starts.</p>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
[checked]="reopenLastViewedChat()"
(change)="onReopenLastViewedChatChange($event)"
id="general-reopen-last-chat-toggle"
aria-label="Toggle reopen last chat on launch"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
<div
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
[class.opacity-60]="!isElectron"

View File

@@ -9,6 +9,10 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePower } from '@ng-icons/lucide';
import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models';
import {
loadGeneralSettingsFromStorage,
saveGeneralSettingsToStorage
} from '../../../../infrastructure/persistence';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';
@@ -28,17 +32,29 @@ export class GeneralSettingsComponent {
private electronBridge = inject(ElectronBridgeService);
readonly isElectron = this.platform.isElectron;
reopenLastViewedChat = signal(true);
autoStart = signal(false);
closeToTray = signal(true);
savingAutoStart = signal(false);
savingCloseToTray = signal(false);
constructor() {
this.loadGeneralSettings();
if (this.isElectron) {
void this.loadDesktopSettings();
}
}
onReopenLastViewedChatChange(event: Event): void {
const input = event.target as HTMLInputElement;
const settings = saveGeneralSettingsToStorage({
reopenLastViewedChat: !!input.checked
});
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
}
async onAutoStartChange(event: Event): Promise<void> {
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);

View File

@@ -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

View File

@@ -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<GeneralSettings>);
} catch {
return { ...DEFAULT_GENERAL_SETTINGS };
}
}
export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): 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<LastViewedChatSnapshot>);
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>): GeneralSettings {
return {
reopenLastViewedChat:
typeof raw.reopenLastViewedChat === 'boolean'
? raw.reopenLastViewedChat
: DEFAULT_GENERAL_SETTINGS.reopenLastViewedChat
};
}
function normaliseLastViewedChatSnapshot(raw: Partial<LastViewedChatSnapshot>): 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
};
}

View File

@@ -1 +1,2 @@
export * from './app-resume.storage';
export * from './database.service';

View File

@@ -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(