message date spacer

This commit is contained in:
Michael Peters 2022-02-10 17:36:16 -06:00
parent 43814a0b6e
commit 73e81f0bc9
5 changed files with 185 additions and 35 deletions

View File

@ -94,7 +94,13 @@ export class Member implements WithEquals<Member> {
} }
export class Channel implements WithEquals<Channel> { export class Channel implements WithEquals<Channel> {
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 { static fromDBData(dataChannel: any): Channel {
return new Channel(dataChannel.id, dataChannel.index, dataChannel.name, dataChannel.flavor_text, dataChannel); return new Channel(dataChannel.id, dataChannel.index, dataChannel.name, dataChannel.flavor_text, dataChannel);
@ -109,7 +115,12 @@ export class Channel implements WithEquals<Channel> {
} }
equals(other: Channel) { 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<Message> {
const channel = this.channel instanceof Channel ? this.channel.name : `ch#${this.channel.id}`; 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}`; const member = this.member instanceof Member ? this.member.displayName : `m#${this.member.id}`;
if (this.resourceName) { 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 { } else {
return `msg#${this.id} ${channel} @${formatDate(this.sent)} ${member}: ${this.text}`; return `msg#${this.id} ${channel} @${formatDate(this.sent)} ${member}: ${this.text}`;
} }
@ -222,11 +235,24 @@ export class Message implements WithEquals<Message> {
); );
} }
// 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 // 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 // messages sent in quick succession (within 5 minutes) of the previous message
// by the same sender are considered continued messages. // by the same sender are considered continued messages.
isContinued(lastMessage: Message | null): boolean { 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 { hasResource(): boolean {
@ -236,7 +262,10 @@ export class Message implements WithEquals<Message> {
isImageResource(): boolean { isImageResource(): boolean {
return !!( return !!(
this.hasResource() && 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 { 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<GuildMetadata> { export class GuildMetadata implements WithEquals<GuildMetadata> {
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) { static fromDBData(data: any) {
return new GuildMetadata(data.name, data.icon_resource_id, data); return new GuildMetadata(data.name, data.icon_resource_id, data);
@ -274,7 +315,13 @@ export class GuildMetadata implements WithEquals<GuildMetadata> {
} }
export class GuildMetadataLocal extends GuildMetadata { 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); super(name, iconResourceId, source);
} }
@ -300,7 +347,14 @@ export class Resource implements WithEquals<Resource> {
} }
export class Token implements WithEquals<Token> { export class Token implements WithEquals<Token> {
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<string, Member>) { fill(members: Map<string, Member>) {
if (this.member) { if (this.member) {
@ -312,11 +366,24 @@ export class Token implements WithEquals<Token> {
} }
static fromDBData(dataToken: any): Token { 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) { 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) { static sortFurthestExpiresFirst(a: Token, b: Token) {

View File

@ -1,5 +1,24 @@
@use '../../../styles/theme.scss'; @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 { .message-react {
display: flex; display: flex;
padding: 4px 16px; padding: 4px 16px;

View File

@ -5,7 +5,12 @@ import { Member, Message } from '../../../data-types';
import CombinedGuild from '../../../guild-combined'; import CombinedGuild from '../../../guild-combined';
import ImageContextMenu from '../../contexts/context-menu-image'; import ImageContextMenu from '../../contexts/context-menu-image';
import ImageOverlay from '../../overlays/overlay-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 ElementsUtil, { IAlignment } from '../../require/elements-util';
import { isLoaded } from '../../require/loadables'; import { isLoaded } from '../../require/loadables';
import { useContextClickContextMenu, useDownloadButton, useOneTimeAsyncAction } from '../../require/react-helper'; import { useContextClickContextMenu, useDownloadButton, useOneTimeAsyncAction } from '../../require/react-helper';
@ -19,14 +24,19 @@ interface ResourceElementProps {
const ResourceElement: FC<ResourceElementProps> = (props: ResourceElementProps) => { const ResourceElement: FC<ResourceElementProps> = (props: ResourceElementProps) => {
const { guild, resourceId, resourceName } = props; const { guild, resourceId, resourceName } = props;
const [callable, text, shaking] = useDownloadButton(resourceName, async () => (await guild.fetchResource(resourceId)).data, [guild, resourceId], { const [callable, text, shaking] = useDownloadButton(
start: 'Click to Download', resourceName,
pendingFetch: 'Fetching...', async () => (await guild.fetchResource(resourceId)).data,
errorFetch: 'Unable to Download. Try Again', [guild, resourceId],
pendingSave: 'Saving...', {
errorSave: 'Unable to Save. Try Again', start: 'Click to Download',
success: 'Click to Open in Explorer', pendingFetch: 'Fetching...',
}); errorFetch: 'Unable to Download. Try Again',
pendingSave: 'Saving...',
errorSave: 'Unable to Save. Try Again',
success: 'Click to Open in Explorer',
},
);
return ( return (
<div className="content resource" onClick={callable}> <div className="content resource" onClick={callable}>
@ -55,12 +65,22 @@ const PreviewImageElement: FC<PreviewImageElementProps> = (props: PreviewImageEl
// TODO: Handle resourceError // TODO: Handle resourceError
const previewResource = useRecoilValue(guildResourceState({ guildId: guild.id, resourceId: resourcePreviewId })); 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( const [contextMenu, onContextMenu] = useContextClickContextMenu(
(_alignment: IAlignment, relativeToPos: { x: number; y: number }, close: () => void) => { (_alignment: IAlignment, relativeToPos: { x: number; y: number }, close: () => void) => {
if (!isLoaded(previewResource)) return null; if (!isLoaded(previewResource)) return null;
return <ImageContextMenu relativeToPos={relativeToPos} close={close} resourceName={resourceName} resourceBuff={previewResource.value.data} isPreview={true} />; return (
<ImageContextMenu
relativeToPos={relativeToPos}
close={close}
resourceName={resourceName}
resourceBuff={previewResource.value.data}
isPreview={true}
/>
);
}, },
[previewResource, resourceName], [previewResource, resourceName],
); );
@ -86,7 +106,10 @@ export interface MessageElementProps {
const MessageElement: FC<MessageElementProps> = (props: MessageElementProps) => { const MessageElement: FC<MessageElementProps> = (props: MessageElementProps) => {
const { guild, message, prevMessage } = props; 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( const [avatarSrc] = useOneTimeAsyncAction(
async () => { async () => {
@ -98,6 +121,17 @@ const MessageElement: FC<MessageElementProps> = (props: MessageElementProps) =>
[message], [message],
); );
const dateSpacer = useMemo(() => {
if (message.isContinued(prevMessage)) return null;
return (
<div className="date-spacer">
<div className="horizontal-line" />
<div className="date">{moment(message.sent).format('MMMM DD, YYYY')}</div>
<div className="horizontal-line" />
</div>
);
}, [message, prevMessage]);
const leftPart = useMemo(() => { const leftPart = useMemo(() => {
if (message.isContinued(prevMessage)) { if (message.isContinued(prevMessage)) {
return <div className="timestamp">{moment(message.sent).format('HH:mm')}</div>; return <div className="timestamp">{moment(message.sent).format('HH:mm')}</div>;
@ -152,19 +186,35 @@ const MessageElement: FC<MessageElementProps> = (props: MessageElementProps) =>
/> />
); );
} else { } else {
return <ResourceElement guild={guild} resourceId={message.resourceId} resourceName={message.resourceName ?? 'unknown.unk'} />; return (
<ResourceElement
guild={guild}
resourceId={message.resourceId}
resourceName={message.resourceName ?? 'unknown.unk'}
/>
);
} }
}, [guild, message.previewHeight, message.previewWidth, message.resourceId, message.resourceName, message.resourcePreviewId]); }, [
guild,
message.previewHeight,
message.previewWidth,
message.resourceId,
message.resourceName,
message.resourcePreviewId,
]);
return ( return (
<div className={className}> <>
{leftPart} {dateSpacer}
<div className="right"> <div className={className}>
{header} {leftPart}
{resourceElement} <div className="right">
<div className="content text">{ElementsUtil.parseMessageText(message.text ?? '')}</div> {header}
{resourceElement}
<div className="content text">{ElementsUtil.parseMessageText(message.text ?? '')}</div>
</div>
</div> </div>
</div> </>
); );
}; };

View File

@ -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 // 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 // there is no way to cancel this function
// useful for enabling a "shake" element for a pre-determined amount of time // useful for enabling a "shake" element for a pre-determined amount of time
static async delayToggleState(setState: React.Dispatch<React.SetStateAction<boolean>>, delayMs: number, start = true): Promise<void> { static async delayToggleState(
setState: React.Dispatch<React.SetStateAction<boolean>>,
delayMs: number,
start = true,
): Promise<void> {
setState(start); setState(start);
await Util.sleep(delayMs); await Util.sleep(delayMs);
setState(_old => !start); setState(_old => !start);
@ -119,7 +123,11 @@ export default class ElementsUtil {
const obj: SimpleQElement = { tag: 'span', content: [], class: null }; const obj: SimpleQElement = { tag: 'span', content: [], class: null };
const stack: SimpleQElement[] = [obj]; const stack: SimpleQElement[] = [obj];
let idx = 0; 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 // function for readability
return { return {
matcher: regex, 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 // 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 // relativeTo can be an { x, y } or HTMLElement
/** aligns an element relative to another element or a position in the window */ /** 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) { function getOffset(alignment: string, name: string) {
const added = name + ' +'; const added = name + ' +';
const subbed = name + ' -'; const subbed = name + ' -';

View File

@ -39,6 +39,8 @@ $background-dropdown-option-hover: #1a1c1f;
$channels-default: #8e9297; $channels-default: #8e9297;
$channeltextarea-background: #40444b; $channeltextarea-background: #40444b;
$background-date-spacer: #41454c;
$brand: #7289ba; /* yea that's a direct copy from discord */ $brand: #7289ba; /* yea that's a direct copy from discord */
$brand-hover: #677bc4; /* thicc */ $brand-hover: #677bc4; /* thicc */
$error: #d71f28; /* In fact, not copied! */ $error: #d71f28; /* In fact, not copied! */