Initial commit

This commit is contained in:
Jack Kingsman
2026-01-06 19:59:51 -08:00
commit 557cb12879
82 changed files with 387739 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
/**
* Generate consistent profile "images" for contacts.
*
* Uses the contact's public key to generate a consistent background color,
* and extracts initials or emoji from the name for display.
* Repeaters (type=2) always show 🛜 with a gray background.
*/
// Contact type constants (matches backend)
export const CONTACT_TYPE_REPEATER = 2;
// Repeater avatar styling
const REPEATER_AVATAR = {
text: '🛜',
background: '#444444',
textColor: '#ffffff',
};
// Simple hash function for strings
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
// Regex to match emoji (covers most common emoji ranges)
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
/**
* Extract display characters from a contact name.
* Priority:
* 1. First emoji in the name
* 2. First letter + first letter after first space (initials)
* 3. First letter only
*/
export function getAvatarText(name: string | null, publicKey: string): string {
if (!name) {
// Use first 2 chars of public key as fallback
return publicKey.slice(0, 2).toUpperCase();
}
// Check for emoji first
const emojiMatch = name.match(emojiRegex);
if (emojiMatch) {
return emojiMatch[0];
}
// Find first letter
const letters = name.match(/[a-zA-Z]/g);
if (!letters || letters.length === 0) {
// No letters, use first 2 chars of public key
return publicKey.slice(0, 2).toUpperCase();
}
// Check for space - get initials
const spaceIndex = name.indexOf(' ');
if (spaceIndex !== -1) {
const firstLetter = letters[0];
// Find first letter after the space
const afterSpace = name.slice(spaceIndex + 1).match(/[a-zA-Z]/);
if (afterSpace) {
return (firstLetter + afterSpace[0]).toUpperCase();
}
}
// Single letter
return letters[0].toUpperCase();
}
/**
* Generate a consistent HSL color from a public key.
* Uses saturation and lightness ranges that work well for backgrounds.
*/
export function getAvatarColor(publicKey: string): {
background: string;
text: string;
} {
const hash = hashString(publicKey);
// Use hash to generate hue (0-360)
const hue = hash % 360;
// Use different bits of hash for saturation variation (50-80%)
const saturation = 50 + ((hash >> 8) % 30);
// Lightness in a range that allows readable text (35-55%)
const lightness = 35 + ((hash >> 16) % 20);
const background = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
// Calculate perceived luminance to determine text color
// For HSL, we can approximate: if lightness < 50%, use white text
// We'll use a slightly lower threshold since saturated colors appear darker
const textColor = lightness < 45 ? '#ffffff' : '#000000';
return { background, text: textColor };
}
/**
* Get all avatar properties for a contact.
* Repeaters (type=2) always get a special gray avatar with 🛜.
*/
export function getContactAvatar(
name: string | null,
publicKey: string,
contactType?: number
): {
text: string;
background: string;
textColor: string;
} {
// Repeaters always get the repeater avatar
if (contactType === CONTACT_TYPE_REPEATER) {
return REPEATER_AVATAR;
}
const text = getAvatarText(name, publicKey);
const colors = getAvatarColor(publicKey);
return {
text,
background: colors.background,
textColor: colors.text,
};
}
+80
View File
@@ -0,0 +1,80 @@
/**
* localStorage utilities for tracking conversation read/message state.
*
* Stores two maps:
* - lastMessageTime: when each conversation last received a message
* - lastReadTime: when the user last viewed each conversation
*
* A conversation has unread messages if lastMessageTime > lastReadTime.
*/
import { getPubkeyPrefix } from './pubkey';
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
const LAST_READ_KEY = 'remoteterm-lastReadTime';
export type ConversationTimes = Record<string, number>;
function loadTimes(key: string): ConversationTimes {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function saveTimes(key: string, times: ConversationTimes): void {
try {
localStorage.setItem(key, JSON.stringify(times));
} catch {
// localStorage might be full or disabled
}
}
export function getLastMessageTimes(): ConversationTimes {
return loadTimes(LAST_MESSAGE_KEY);
}
export function getLastReadTimes(): ConversationTimes {
return loadTimes(LAST_READ_KEY);
}
export function setLastMessageTime(stateKey: string, timestamp: number): ConversationTimes {
const times = loadTimes(LAST_MESSAGE_KEY);
// Only update if this is a newer message
if (!times[stateKey] || timestamp > times[stateKey]) {
times[stateKey] = timestamp;
saveTimes(LAST_MESSAGE_KEY, times);
}
return times;
}
export function setLastReadTime(stateKey: string, timestamp: number): ConversationTimes {
const times = loadTimes(LAST_READ_KEY);
times[stateKey] = timestamp;
saveTimes(LAST_READ_KEY, times);
return times;
}
/**
* Generate a state tracking key for unread counts and message times.
*
* This is NOT the same as Message.conversation_key (the database field).
* This creates prefixed keys for localStorage/state tracking:
* - Channels: "channel-{channelKey}"
* - Contacts: "contact-{12-char-pubkey-prefix}"
*
* The 12-char prefix for contacts ensures consistent matching regardless
* of whether we have a full 64-char pubkey or just a prefix.
*/
export function getStateKey(
type: 'channel' | 'contact',
id: string
): string {
if (type === 'channel') {
return `channel-${id}`;
}
// For contacts, use 12-char prefix for consistent matching
return `contact-${getPubkeyPrefix(id)}`;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Parse sender from channel message text.
* Channel messages have format "sender: message".
*/
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': ');
if (colonIndex > 0 && colonIndex < 50) {
const potentialSender = text.substring(0, colonIndex);
// Check for invalid characters that would indicate it's not a sender
if (!/[:\[\]]/.test(potentialSender)) {
return {
sender: potentialSender,
content: text.substring(colonIndex + 2),
};
}
}
return { sender: null, content: text };
}
/**
* Format a Unix timestamp to a time string.
* Shows date for messages not from today.
*/
export function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) {
return time;
}
// Show short date for older messages
const dateStr = date.toLocaleDateString([], { month: 'short', day: 'numeric' });
return `${dateStr} ${time}`;
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Public key utilities for consistent handling of 64-char full keys
* and 12-char prefixes throughout the application.
*
* MeshCore uses 64-character hex strings for public keys, but messages
* and some radio operations only provide 12-character prefixes. This
* module provides utilities for working with both formats consistently.
*/
/** Length of a full public key in hex characters */
export const PUBKEY_FULL_LENGTH = 64;
/** Length of a public key prefix in hex characters */
export const PUBKEY_PREFIX_LENGTH = 12;
/**
* Extract the 12-character prefix from a public key.
* Works with both full keys and existing prefixes.
*/
export function getPubkeyPrefix(key: string): string {
return key.slice(0, PUBKEY_PREFIX_LENGTH);
}
/**
* Check if two public keys match by comparing their prefixes.
* This handles the case where one key is full (64 chars) and
* the other is a prefix (12 chars).
*/
export function pubkeysMatch(a: string, b: string): boolean {
if (!a || !b) return false;
return getPubkeyPrefix(a) === getPubkeyPrefix(b);
}
/**
* Check if a public key starts with the given prefix.
* More explicit than using .startsWith() directly.
*/
export function pubkeyMatchesPrefix(fullKey: string, prefix: string): boolean {
if (!fullKey || !prefix) return false;
return fullKey.startsWith(prefix);
}
/**
* Get a display name for a contact, falling back to pubkey prefix.
*/
export function getContactDisplayName(name: string | null | undefined, pubkey: string): string {
return name || getPubkeyPrefix(pubkey);
}
/**
* Check if a key is a full 64-character public key.
*/
export function isFullPubkey(key: string): boolean {
return key.length === PUBKEY_FULL_LENGTH;
}
/**
* Check if a key is a 12-character prefix.
*/
export function isPubkeyPrefix(key: string): boolean {
return key.length === PUBKEY_PREFIX_LENGTH;
}