From 73e81f0bc9e95d961664abb2e53e192f57c2a093 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Thu, 10 Feb 2022 17:36:16 -0600 Subject: [PATCH] message date spacer --- src/client/webapp/data-types.ts | 89 +++++++++++++++--- .../lists/components/message-element.scss | 19 ++++ .../lists/components/message-element.tsx | 92 ++++++++++++++----- .../webapp/elements/require/elements-util.tsx | 18 +++- src/client/webapp/styles/theme.scss | 2 + 5 files changed, 185 insertions(+), 35 deletions(-) diff --git a/src/client/webapp/data-types.ts b/src/client/webapp/data-types.ts index 31c2060..392da05 100644 --- a/src/client/webapp/data-types.ts +++ b/src/client/webapp/data-types.ts @@ -94,7 +94,13 @@ export class Member implements WithEquals { } export class Channel implements WithEquals { - private constructor(public readonly id: string, public readonly index: number, public readonly name: string, public readonly flavorText: string | null, public readonly source?: unknown) {} + private constructor( + public readonly id: string, + public readonly index: number, + public readonly name: string, + public readonly flavorText: string | null, + public readonly source?: unknown, + ) {} static fromDBData(dataChannel: any): Channel { return new Channel(dataChannel.id, dataChannel.index, dataChannel.name, dataChannel.flavor_text, dataChannel); @@ -109,7 +115,12 @@ export class Channel implements WithEquals { } equals(other: Channel) { - return this.id === other.id && this.index === other.index && this.name === other.name && this.flavorText === other.flavorText; + return ( + this.id === other.id && + this.index === other.index && + this.name === other.name && + this.flavorText === other.flavorText + ); } } @@ -190,7 +201,9 @@ export class Message implements WithEquals { const channel = this.channel instanceof Channel ? this.channel.name : `ch#${this.channel.id}`; const member = this.member instanceof Member ? this.member.displayName : `m#${this.member.id}`; if (this.resourceName) { - return `msg#${this.id} ${channel} @${formatDate(this.sent)} ${member}: ${this.text ? this.text + ' / ' : ''}${this.resourceName}`; + return `msg#${this.id} ${channel} @${formatDate(this.sent)} ${member}: ${ + this.text ? this.text + ' / ' : '' + }${this.resourceName}`; } else { return `msg#${this.id} ${channel} @${formatDate(this.sent)} ${member}: ${this.text}`; } @@ -222,11 +235,24 @@ export class Message implements WithEquals { ); } + // returns true if the message happens on the next day from the last message + isNextDay(lastMessage: Message | null): boolean { + if (!lastMessage) return true; // this is the first message + const msPerDay = 24 * 60 * 60 * 1000; + const lastMessageDay = Math.floor(lastMessage.sent.getTime() / msPerDay); + const messageDay = Math.floor(this.sent.getTime() / msPerDay); + return messageDay !== lastMessageDay; + } + // returns true if this message should be considered 'continued' from the last message // messages sent in quick succession (within 5 minutes) of the previous message // by the same sender are considered continued messages. isContinued(lastMessage: Message | null): boolean { - return !!(lastMessage && lastMessage.member.id === this.member.id && this.sent.getTime() - lastMessage.sent.getTime() < 5 * 60 * 1000); + return !!( + lastMessage && + lastMessage.member.id === this.member.id && + this.sent.getTime() - lastMessage.sent.getTime() < 5 * 60 * 1000 + ); } hasResource(): boolean { @@ -236,7 +262,10 @@ export class Message implements WithEquals { isImageResource(): boolean { return !!( this.hasResource() && - (this.resourceName?.toLowerCase().endsWith('.png') || this.resourceName?.toLowerCase().endsWith('.jpg') || this.resourceName?.toLowerCase().endsWith('.jpeg') || this.resourceName?.toLowerCase().endsWith('.gif')) + (this.resourceName?.toLowerCase().endsWith('.png') || + this.resourceName?.toLowerCase().endsWith('.jpg') || + this.resourceName?.toLowerCase().endsWith('.jpeg') || + this.resourceName?.toLowerCase().endsWith('.gif')) ); } } @@ -253,12 +282,24 @@ export class SocketConfig { ) {} static fromDBData(data: any): SocketConfig { - return new SocketConfig(data.id, data.guild_id, data.url, data.cert, crypto.createPublicKey(data.public_key), crypto.createPrivateKey(data.private_key), data); + return new SocketConfig( + data.id, + data.guild_id, + data.url, + data.cert, + crypto.createPublicKey(data.public_key), + crypto.createPrivateKey(data.private_key), + data, + ); } } export class GuildMetadata implements WithEquals { - public constructor(public readonly name: string, public readonly iconResourceId: string, public readonly source?: unknown) {} + public constructor( + public readonly name: string, + public readonly iconResourceId: string, + public readonly source?: unknown, + ) {} static fromDBData(data: any) { return new GuildMetadata(data.name, data.icon_resource_id, data); @@ -274,7 +315,13 @@ export class GuildMetadata implements WithEquals { } export class GuildMetadataLocal extends GuildMetadata { - private constructor(public readonly id: number, name: string, iconResourceId: string, public readonly memberId: string | null, source?: unknown) { + private constructor( + public readonly id: number, + name: string, + iconResourceId: string, + public readonly memberId: string | null, + source?: unknown, + ) { super(name, iconResourceId, source); } @@ -300,7 +347,14 @@ export class Resource implements WithEquals { } export class Token implements WithEquals { - constructor(public readonly id: string, public readonly token: string, public member: Member | { id: string } | null, public readonly created: Date, public readonly expires: Date | null, public readonly source?: unknown) {} + constructor( + public readonly id: string, + public readonly token: string, + public member: Member | { id: string } | null, + public readonly created: Date, + public readonly expires: Date | null, + public readonly source?: unknown, + ) {} fill(members: Map) { if (this.member) { @@ -312,11 +366,24 @@ export class Token implements WithEquals { } static fromDBData(dataToken: any): Token { - return new Token(dataToken.id, dataToken.token, dataToken.member_id ? { id: dataToken.member_id } : null, new Date(dataToken.created), dataToken.expires ? new Date(dataToken.expires) : null, dataToken); + return new Token( + dataToken.id, + dataToken.token, + dataToken.member_id ? { id: dataToken.member_id } : null, + new Date(dataToken.created), + dataToken.expires ? new Date(dataToken.expires) : null, + dataToken, + ); } equals(other: Token) { - return this.id === other.id && this.token === other.token && this.member?.id === other.member?.id && this.created === other.created && this.expires === other.expires; + return ( + this.id === other.id && + this.token === other.token && + this.member?.id === other.member?.id && + this.created === other.created && + this.expires === other.expires + ); } static sortFurthestExpiresFirst(a: Token, b: Token) { diff --git a/src/client/webapp/elements-styles/lists/components/message-element.scss b/src/client/webapp/elements-styles/lists/components/message-element.scss index 8f3eef9..5d94a10 100644 --- a/src/client/webapp/elements-styles/lists/components/message-element.scss +++ b/src/client/webapp/elements-styles/lists/components/message-element.scss @@ -1,5 +1,24 @@ @use '../../../styles/theme.scss'; +.date-spacer { + display: flex; + align-items: center; + margin: 8px 16px; + + .horizontal-line { + flex: 1; + height: 1px; + background-color: theme.$background-date-spacer; + } + + .date { + font-size: 0.7em; + font-weight: 600; + color: theme.$text-muted; + margin: 0 4px; + } +} + .message-react { display: flex; padding: 4px 16px; diff --git a/src/client/webapp/elements/lists/components/message-element.tsx b/src/client/webapp/elements/lists/components/message-element.tsx index 806ccaa..f517f1b 100644 --- a/src/client/webapp/elements/lists/components/message-element.tsx +++ b/src/client/webapp/elements/lists/components/message-element.tsx @@ -5,7 +5,12 @@ import { Member, Message } from '../../../data-types'; import CombinedGuild from '../../../guild-combined'; import ImageContextMenu from '../../contexts/context-menu-image'; import ImageOverlay from '../../overlays/overlay-image'; -import { guildResourceSoftImgSrcState, guildResourceState, overlayState, useRecoilValueSoftImgSrc } from '../../require/atoms'; +import { + guildResourceSoftImgSrcState, + guildResourceState, + overlayState, + useRecoilValueSoftImgSrc, +} from '../../require/atoms'; import ElementsUtil, { IAlignment } from '../../require/elements-util'; import { isLoaded } from '../../require/loadables'; import { useContextClickContextMenu, useDownloadButton, useOneTimeAsyncAction } from '../../require/react-helper'; @@ -19,14 +24,19 @@ interface ResourceElementProps { const ResourceElement: FC = (props: ResourceElementProps) => { const { guild, resourceId, resourceName } = props; - const [callable, text, shaking] = useDownloadButton(resourceName, async () => (await guild.fetchResource(resourceId)).data, [guild, resourceId], { - start: 'Click to Download', - pendingFetch: 'Fetching...', - errorFetch: 'Unable to Download. Try Again', - pendingSave: 'Saving...', - errorSave: 'Unable to Save. Try Again', - success: 'Click to Open in Explorer', - }); + const [callable, text, shaking] = useDownloadButton( + resourceName, + async () => (await guild.fetchResource(resourceId)).data, + [guild, resourceId], + { + start: 'Click to Download', + pendingFetch: 'Fetching...', + errorFetch: 'Unable to Download. Try Again', + pendingSave: 'Saving...', + errorSave: 'Unable to Save. Try Again', + success: 'Click to Open in Explorer', + }, + ); return (
@@ -55,12 +65,22 @@ const PreviewImageElement: FC = (props: PreviewImageEl // TODO: Handle resourceError const previewResource = useRecoilValue(guildResourceState({ guildId: guild.id, resourceId: resourcePreviewId })); - const previewImgSrc = useRecoilValueSoftImgSrc(guildResourceSoftImgSrcState({ guildId: guild.id, resourceId: resourcePreviewId })); + const previewImgSrc = useRecoilValueSoftImgSrc( + guildResourceSoftImgSrcState({ guildId: guild.id, resourceId: resourcePreviewId }), + ); const [contextMenu, onContextMenu] = useContextClickContextMenu( (_alignment: IAlignment, relativeToPos: { x: number; y: number }, close: () => void) => { if (!isLoaded(previewResource)) return null; - return ; + return ( + + ); }, [previewResource, resourceName], ); @@ -86,7 +106,10 @@ export interface MessageElementProps { const MessageElement: FC = (props: MessageElementProps) => { const { guild, message, prevMessage } = props; - const className = useMemo(() => (message.isContinued(prevMessage) ? 'message-react continued' : 'message-react'), [message, prevMessage]); + const className = useMemo( + () => (message.isContinued(prevMessage) ? 'message-react continued' : 'message-react'), + [message, prevMessage], + ); const [avatarSrc] = useOneTimeAsyncAction( async () => { @@ -98,6 +121,17 @@ const MessageElement: FC = (props: MessageElementProps) => [message], ); + const dateSpacer = useMemo(() => { + if (message.isContinued(prevMessage)) return null; + return ( +
+
+
{moment(message.sent).format('MMMM DD, YYYY')}
+
+
+ ); + }, [message, prevMessage]); + const leftPart = useMemo(() => { if (message.isContinued(prevMessage)) { return
{moment(message.sent).format('HH:mm')}
; @@ -152,19 +186,35 @@ const MessageElement: FC = (props: MessageElementProps) => /> ); } else { - return ; + return ( + + ); } - }, [guild, message.previewHeight, message.previewWidth, message.resourceId, message.resourceName, message.resourcePreviewId]); + }, [ + guild, + message.previewHeight, + message.previewWidth, + message.resourceId, + message.resourceName, + message.resourcePreviewId, + ]); return ( -
- {leftPart} -
- {header} - {resourceElement} -
{ElementsUtil.parseMessageText(message.text ?? '')}
+ <> + {dateSpacer} +
+ {leftPart} +
+ {header} + {resourceElement} +
{ElementsUtil.parseMessageText(message.text ?? '')}
+
-
+ ); }; diff --git a/src/client/webapp/elements/require/elements-util.tsx b/src/client/webapp/elements/require/elements-util.tsx index 4062b18..53c3bf7 100644 --- a/src/client/webapp/elements/require/elements-util.tsx +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -50,7 +50,11 @@ export default class ElementsUtil { // calls a function with the start parameter and then the inverse of the start parameter after a determined number of ms // there is no way to cancel this function // useful for enabling a "shake" element for a pre-determined amount of time - static async delayToggleState(setState: React.Dispatch>, delayMs: number, start = true): Promise { + static async delayToggleState( + setState: React.Dispatch>, + delayMs: number, + start = true, + ): Promise { setState(start); await Util.sleep(delayMs); setState(_old => !start); @@ -119,7 +123,11 @@ export default class ElementsUtil { const obj: SimpleQElement = { tag: 'span', content: [], class: null }; const stack: SimpleQElement[] = [obj]; let idx = 0; - function makeEscape(regex: RegExp, len: number, str: string): { matcher: RegExp; response: (i: number) => void } { + function makeEscape( + regex: RegExp, + len: number, + str: string, + ): { matcher: RegExp; response: (i: number) => void } { // function for readability return { matcher: regex, @@ -203,7 +211,11 @@ export default class ElementsUtil { // and align the left of the element to the right of the relative to plus 20 pixels // relativeTo can be an { x, y } or HTMLElement /** aligns an element relative to another element or a position in the window */ - static alignContextElement(element: HTMLElement, relativeTo: HTMLElement | { x: number; y: number }, alignment: IAlignment): void { + static alignContextElement( + element: HTMLElement, + relativeTo: HTMLElement | { x: number; y: number }, + alignment: IAlignment, + ): void { function getOffset(alignment: string, name: string) { const added = name + ' +'; const subbed = name + ' -'; diff --git a/src/client/webapp/styles/theme.scss b/src/client/webapp/styles/theme.scss index 356ac68..e3a99d4 100644 --- a/src/client/webapp/styles/theme.scss +++ b/src/client/webapp/styles/theme.scss @@ -39,6 +39,8 @@ $background-dropdown-option-hover: #1a1c1f; $channels-default: #8e9297; $channeltextarea-background: #40444b; +$background-date-spacer: #41454c; + $brand: #7289ba; /* yea that's a direct copy from discord */ $brand-hover: #677bc4; /* thicc */ $error: #d71f28; /* In fact, not copied! */