mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
Merge origin/main into main
Resolved 2 messenger conflicts: - FriendsMessengerView.tsx: union — kept local typingUserIds/sendTypingStatus from useMessenger() plus upstream's useFriends().getFriend. - FriendsView.css: kept local group-chips + typing-indicator styles (upstream empty there). Vitest 545/545 green. (typecheck TS2307 is the un-linked renderer, env-only.)
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
export * from './MentionType';
|
||||
export * from './IMentionEntry';
|
||||
export * from './mentionTokens';
|
||||
export * from './mentionsFormat';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyMentionToken, MENTION_ROOM_ALIASES, tokenIsMention } from './mentionTokens';
|
||||
|
||||
describe('classifyMentionToken', () =>
|
||||
{
|
||||
it('returns "self" for the own nick', () =>
|
||||
{
|
||||
expect(classifyMentionToken('@Bob', 'Bob')).toBe('self');
|
||||
});
|
||||
|
||||
it('returns "self" for a broadcast alias', () =>
|
||||
{
|
||||
expect(classifyMentionToken('@all', 'Bob')).toBe('self');
|
||||
|
||||
for(const alias of MENTION_ROOM_ALIASES)
|
||||
{
|
||||
expect(classifyMentionToken(`@${ alias }`, 'Bob')).toBe('self');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns "tag" for any other @user', () =>
|
||||
{
|
||||
expect(classifyMentionToken('@Charlie', 'Bob')).toBe('tag');
|
||||
});
|
||||
|
||||
it('matches the own nick case-insensitively', () =>
|
||||
{
|
||||
expect(classifyMentionToken('@bOb', 'BOB')).toBe('self');
|
||||
});
|
||||
|
||||
it('returns "" for non-mentions and a bare @', () =>
|
||||
{
|
||||
expect(classifyMentionToken('@', 'Bob')).toBe('');
|
||||
expect(classifyMentionToken('nothing', 'Bob')).toBe('');
|
||||
});
|
||||
|
||||
it('still tags others when the own username is empty', () =>
|
||||
{
|
||||
expect(classifyMentionToken('@Charlie', '')).toBe('tag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenIsMention', () =>
|
||||
{
|
||||
it('is true only for self/alias mentions', () =>
|
||||
{
|
||||
expect(tokenIsMention('@Bob', 'Bob')).toBe(true);
|
||||
expect(tokenIsMention('@everyone', 'Bob')).toBe(true);
|
||||
expect(tokenIsMention('@Charlie', 'Bob')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Shared @-mention token classification, used by both the chat-bubble
|
||||
// highlighter and the mentions panel so the two can't diverge.
|
||||
|
||||
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
|
||||
'all', 'everyone', 'tutti',
|
||||
'friends', 'amici',
|
||||
'room', 'stanza'
|
||||
];
|
||||
|
||||
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
|
||||
|
||||
const normalizeToken = (token: string): string =>
|
||||
{
|
||||
if(!token || (token.length < 2) || (token.charAt(0) !== '@')) return '';
|
||||
|
||||
return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
};
|
||||
|
||||
const normalizeNick = (value: string): string => (value || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
|
||||
// '' = not a mention; 'tag' = any @user (subtle chip); 'self' = the token
|
||||
// targets the viewer (own nick) or is a broadcast alias (strong highlight).
|
||||
export type MentionKind = '' | 'tag' | 'self';
|
||||
|
||||
export const classifyMentionToken = (
|
||||
token: string,
|
||||
ownUsername: string,
|
||||
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
|
||||
): MentionKind =>
|
||||
{
|
||||
const nick = normalizeToken(token);
|
||||
|
||||
if(!nick) return '';
|
||||
|
||||
const ownLower = normalizeNick(ownUsername);
|
||||
|
||||
if((ownLower && (nick === ownLower)) || aliases.some(alias => alias.toLowerCase() === nick)) return 'self';
|
||||
|
||||
return 'tag';
|
||||
};
|
||||
|
||||
/**
|
||||
* Back-compat boolean — true only when the token targets the viewer or a
|
||||
* broadcast alias (i.e. "I was mentioned"), not for generic @user tags.
|
||||
*/
|
||||
export const tokenIsMention = (
|
||||
token: string,
|
||||
ownUsername: string,
|
||||
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
|
||||
): boolean => (classifyMentionToken(token, ownUsername, aliases) === 'self');
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatMentionTime, getMentionDateGroup } from './mentionsFormat';
|
||||
|
||||
// Fixed reference "now": 2026-06-02 14:30 local time.
|
||||
const NOW = new Date(2026, 5, 2, 14, 30, 0);
|
||||
const at = (y: number, mo: number, d: number, h = 12, mi = 0): number => Math.floor(new Date(y, mo, d, h, mi, 0).getTime() / 1000);
|
||||
|
||||
describe('getMentionDateGroup', () =>
|
||||
{
|
||||
it('buckets same-day as today', () =>
|
||||
{
|
||||
expect(getMentionDateGroup(at(2026, 5, 2, 9, 15), NOW)).toBe('today');
|
||||
});
|
||||
|
||||
it('buckets previous day as yesterday', () =>
|
||||
{
|
||||
expect(getMentionDateGroup(at(2026, 5, 1, 23, 59), NOW)).toBe('yesterday');
|
||||
});
|
||||
|
||||
it('buckets two+ days ago as older', () =>
|
||||
{
|
||||
expect(getMentionDateGroup(at(2026, 4, 28, 10, 0), NOW)).toBe('older');
|
||||
});
|
||||
|
||||
it('treats missing/zero timestamp as older', () =>
|
||||
{
|
||||
expect(getMentionDateGroup(0, NOW)).toBe('older');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMentionTime', () =>
|
||||
{
|
||||
it('shows HH:MM (zero-padded) for today', () =>
|
||||
{
|
||||
expect(formatMentionTime(at(2026, 5, 2, 9, 5), NOW)).toBe('09:05');
|
||||
});
|
||||
|
||||
it('shows HH:MM for yesterday', () =>
|
||||
{
|
||||
expect(formatMentionTime(at(2026, 5, 1, 18, 45), NOW)).toBe('18:45');
|
||||
});
|
||||
|
||||
it('shows DD-MM for older entries', () =>
|
||||
{
|
||||
expect(formatMentionTime(at(2026, 4, 28, 10, 0), NOW)).toBe('28-05');
|
||||
});
|
||||
|
||||
it('returns empty string for missing timestamp', () =>
|
||||
{
|
||||
expect(formatMentionTime(0, NOW)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Date/time helpers for the mentions box. Kept framework-free and pure so they
|
||||
// are unit-testable. Timestamps are unix SECONDS (as carried on the wire).
|
||||
|
||||
export type MentionDateGroup = 'today' | 'yesterday' | 'older';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
const startOfDay = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
|
||||
const pad = (n: number): string => (n < 10 ? `0${ n }` : `${ n }`);
|
||||
|
||||
/**
|
||||
* Bucket a mention timestamp into today / yesterday / older relative to `now`.
|
||||
*/
|
||||
export const getMentionDateGroup = (timestampSeconds: number, now: Date = new Date()): MentionDateGroup =>
|
||||
{
|
||||
if(!timestampSeconds || timestampSeconds <= 0) return 'older';
|
||||
|
||||
const ts = timestampSeconds * 1000;
|
||||
const todayStart = startOfDay(now);
|
||||
|
||||
if(ts >= todayStart) return 'today';
|
||||
if(ts >= (todayStart - DAY_MS)) return 'yesterday';
|
||||
|
||||
return 'older';
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact per-row time label: HH:MM for today/yesterday (the section header
|
||||
* disambiguates the day), DD-MM for older entries. Empty string when unknown.
|
||||
*/
|
||||
export const formatMentionTime = (timestampSeconds: number, now: Date = new Date()): string =>
|
||||
{
|
||||
if(!timestampSeconds || timestampSeconds <= 0) return '';
|
||||
|
||||
const d = new Date(timestampSeconds * 1000);
|
||||
|
||||
if(getMentionDateGroup(timestampSeconds, now) === 'older') return `${ pad(d.getDate()) }-${ pad(d.getMonth() + 1) }`;
|
||||
|
||||
return `${ pad(d.getHours()) }:${ pad(d.getMinutes()) }`;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IMentionEntry } from '../mentions';
|
||||
import { NotificationBubbleItem } from './NotificationBubbleItem';
|
||||
import { NotificationBubbleType } from './NotificationBubbleType';
|
||||
|
||||
/**
|
||||
* A notification bubble that carries a full mention entry, so the dedicated
|
||||
* mention bubble layout can render the sender's avatar (from the figure) and
|
||||
* the go-to-room action — data the plain NotificationBubbleItem can't hold.
|
||||
*/
|
||||
export class MentionNotificationBubbleItem extends NotificationBubbleItem
|
||||
{
|
||||
private _mention: IMentionEntry;
|
||||
|
||||
constructor(mention: IMentionEntry)
|
||||
{
|
||||
super(mention.message, NotificationBubbleType.MENTION, null, null, mention.senderUsername);
|
||||
|
||||
this._mention = mention;
|
||||
}
|
||||
|
||||
public get mention(): IMentionEntry
|
||||
{
|
||||
return this._mention;
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,5 @@ export class NotificationBubbleType
|
||||
public static BUYFURNI: string = 'buyfurni';
|
||||
public static VIP: string = 'vip';
|
||||
public static ROOMMESSAGESPOSTED: string = 'roommessagesposted';
|
||||
public static MENTION: string = 'mention';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './NotificationAlertItem';
|
||||
export * from './NotificationAlertType';
|
||||
export * from './MentionNotificationBubbleItem';
|
||||
export * from './NotificationBubbleItem';
|
||||
export * from './NotificationBubbleType';
|
||||
export * from './NotificationConfirmItem';
|
||||
|
||||
Reference in New Issue
Block a user