feat: Add clickable #channel links in chat messages

- Auto-detect #channel names in messages and convert to clickable links
- Click existing channel: switch to it via channel selector
- Click non-existing channel: join via API, then switch
- Green styling to distinguish from blue @mentions
- Only active in channel context (not in DMs)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-02-02 08:54:50 +01:00
parent d37326c261
commit 2c17c83253
2 changed files with 167 additions and 3 deletions

View File

@@ -625,6 +625,45 @@ main {
background-color: #084298;
}
/* Channel Links (#channelname) */
.channel-link {
display: inline-block;
background-color: #198754;
color: white;
padding: 0.15rem 0.5rem;
border-radius: 0.75rem;
font-size: 0.85em;
font-weight: 500;
margin: 0 0.1rem;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.channel-link:hover {
background-color: #157347;
color: white;
text-decoration: none;
}
.message.own .channel-link {
background-color: #0f5132;
}
.message.own .channel-link:hover {
background-color: #0d4429;
}
.channel-link.loading {
opacity: 0.7;
pointer-events: none;
}
.channel-link.loading::after {
content: '...';
}
/* Quoted Text (»text«) */
.quote-text {
font-style: italic;

View File

@@ -18,10 +18,13 @@ function processMessageContent(content) {
// 1. Convert @[Username] mentions to badges
processed = processMentions(processed);
// 2. Convert »quoted text« to styled quotes
// 2. Convert #channel to clickable links (only in channel context)
processed = processChannelLinks(processed);
// 3. Convert »quoted text« to styled quotes
processed = processQuotes(processed);
// 3. Convert URLs to links (and images to thumbnails)
// 4. Convert URLs to links (and images to thumbnails)
processed = processUrls(processed);
return processed;
@@ -43,6 +46,31 @@ function processMentions(text) {
});
}
/**
* Convert #channelname to clickable channel links
* Only active in channel context (when availableChannels exists)
* @param {string} text - HTML-escaped text
* @returns {string} - Text with channel links
*/
function processChannelLinks(text) {
// Only process in channel context (app.js provides availableChannels)
// In DM context (dm.js), availableChannels is undefined
if (typeof availableChannels === 'undefined') {
return text;
}
// Match #channelname pattern
// Valid: alphanumeric, underscore, hyphen
// Must be at least 2 characters after #
// Must be preceded by whitespace, start of string, or punctuation
const channelPattern = /(^|[\s.,!?:;()\[\]])#([a-zA-Z0-9_-]{2,})/g;
return text.replace(channelPattern, (_match, prefix, channelName) => {
const escapedName = escapeHtmlAttribute(channelName);
return `${prefix}<a href="#" class="channel-link" data-channel-name="${escapedName}">#${channelName}</a>`;
});
}
/**
* Convert »quoted text« to styled quote blocks
* @param {string} text - HTML-escaped text
@@ -205,10 +233,107 @@ function initializeImageHandlers() {
});
}
/**
* Handle channel link click - switch to or join channel
* @param {string} channelName - Channel name without # prefix
*/
async function handleChannelLinkClick(channelName) {
// Normalize name (add # if not present for comparison)
const normalizedName = channelName.startsWith('#') ? channelName : '#' + channelName;
// Check if channel exists in availableChannels
const existingChannel = availableChannels.find(
ch => ch.name.toLowerCase() === normalizedName.toLowerCase()
);
if (existingChannel) {
switchToChannel(existingChannel.index, existingChannel.name);
} else {
await joinAndSwitchToChannel(normalizedName);
}
}
/**
* Switch to an existing channel via the channel selector
* @param {number} channelIdx - Channel index
* @param {string} channelName - Channel name for notification
*/
function switchToChannel(channelIdx, channelName) {
const selector = document.getElementById('channelSelector');
if (selector) {
selector.value = channelIdx;
// Trigger change event to update state and load messages
selector.dispatchEvent(new Event('change'));
}
}
/**
* Join a channel via API when clicking channel link, then switch to it
* @param {string} channelName - Channel name (with #)
*/
async function joinAndSwitchToChannel(channelName) {
try {
const response = await fetch('/api/channels/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: channelName })
});
const data = await response.json();
if (data.success) {
showNotification(`Joined channel "${channelName}"!`, 'success');
// Show warning if applicable (e.g., exceeding channel limit)
if (data.warning) {
setTimeout(() => {
showNotification(data.warning, 'warning');
}, 2000);
}
// Reload channels and switch to new channel
await loadChannels();
switchToChannel(data.channel.index, channelName);
} else {
showNotification('Failed to join channel: ' + data.error, 'danger');
}
} catch (error) {
console.error('Error joining channel via link:', error);
showNotification('Failed to join channel', 'danger');
}
}
/**
* Initialize channel link click handlers using event delegation
*/
function initializeChannelLinkHandlers() {
document.addEventListener('click', function(e) {
if (e.target.classList.contains('channel-link')) {
e.preventDefault();
const channelName = e.target.getAttribute('data-channel-name');
if (channelName) {
// Add loading state
e.target.classList.add('loading');
handleChannelLinkClick(channelName).finally(() => {
e.target.classList.remove('loading');
});
}
}
});
}
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeImageHandlers);
document.addEventListener('DOMContentLoaded', function() {
initializeImageHandlers();
initializeChannelLinkHandlers();
});
} else {
// DOM already loaded
initializeImageHandlers();
initializeChannelLinkHandlers();
}