mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Initial commit
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user