Added blocking service.
Some checks failed
CI / lint (push) Failing after 26s
CI / test (3.9) (push) Failing after 25s
CI / test (3.12) (push) Failing after 26s
CI / build (push) Failing after 27s
CI / test (3.8) (push) Failing after 24s
CI / test (3.11) (push) Failing after 47s
CI / test (3.10) (push) Failing after 49s

This commit is contained in:
retoor 2025-12-10 15:39:19 +01:00
parent 2078c1115a
commit 3570ba5b99
12 changed files with 574 additions and 25 deletions

157
css/components/block.css Normal file
View File

@ -0,0 +1,157 @@
.blocked-users-manager {
display: block;
}
.block-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.block-header h3 {
margin: 0;
font-size: 1rem;
color: var(--color-text);
}
.block-count {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.block-add-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.block-input {
flex: 1;
padding: 0.625rem 0.875rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.875rem;
}
.block-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.2);
}
.block-input::placeholder {
color: var(--color-text-muted);
}
.block-add-btn {
flex-shrink: 0;
}
.blocked-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 300px;
overflow-y: auto;
}
.blocked-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: var(--color-surface);
border-radius: var(--radius-md);
}
.blocked-item .blocked-username {
font-size: 0.875rem;
color: var(--color-text);
font-weight: 500;
}
.blocked-empty {
text-align: center;
padding: 2rem;
color: var(--color-text-muted);
}
.blocked-empty p {
margin: 0;
}
.block-actions {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.rant-detail-blocked {
padding: 1rem;
}
.rant-detail-blocked .detail-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.blocked-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--color-text-secondary);
}
.blocked-message svg {
color: var(--color-error);
margin-bottom: 1rem;
opacity: 0.7;
}
.blocked-message p {
margin: 0.25rem 0;
}
.blocked-message .blocked-username {
font-weight: 600;
color: var(--color-text);
margin-bottom: 1rem;
}
.blocked-message .unblock-btn {
margin-top: 1rem;
}
.profile-blocked-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
color: var(--color-error);
margin: 0 1rem 1rem;
font-size: 0.875rem;
}
.profile-blocked-banner svg {
flex-shrink: 0;
}
.block-user-btn {
margin-top: 0.5rem;
}
.btn-small {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}

View File

@ -38,6 +38,7 @@
<link rel="stylesheet" href="css/components/notification.css">
<link rel="stylesheet" href="css/components/form.css">
<link rel="stylesheet" href="css/components/pages.css">
<link rel="stylesheet" href="css/components/block.css">
<link rel="stylesheet" href="lib/highlight.css">
<script src="lib/marked.min.js"></script>

View File

@ -10,6 +10,7 @@ import { StorageService } from './services/storage.js';
import { AuthService } from './services/auth.js';
import { Router } from './services/router.js';
import { ThemeService } from './services/theme.js';
import { BlockService } from './services/block.js';
import { markdownRenderer } from './utils/markdown.js';
import { BaseComponent } from './components/base-component.js';
@ -33,6 +34,7 @@ import { CommentForm } from './components/comment-form.js';
import { UserProfile } from './components/user-profile.js';
import { NotificationList, NotificationItem } from './components/notification-list.js';
import { PostForm, PostModal } from './components/post-form.js';
import { BlockedUsersManager } from './components/blocked-users-manager.js';
import { HomePage } from './pages/home-page.js';
import { RantPage } from './pages/rant-page.js';
@ -52,6 +54,7 @@ class Application {
this.auth = new AuthService(this.api, this.storage);
this.router = new Router();
this.theme = new ThemeService(this.storage);
this.block = new BlockService(this.storage);
this.toast = null;
this.header = null;
this.nav = null;
@ -90,6 +93,7 @@ class Application {
async init() {
await this.auth.init();
this.theme.init();
this.block.init();
await markdownRenderer.init();
this.setupToast();

View File

@ -0,0 +1,111 @@
import { BaseComponent } from './base-component.js';
class BlockedUsersManager extends BaseComponent {
init() {
this.blockChangeHandler = () => this.render();
this.render();
this.bindEvents();
}
render() {
const blockedUsers = this.getBlock()?.getBlockedUsers() || [];
this.addClass('blocked-users-manager');
this.setHtml(`
<div class="block-header">
<h3>Blocked Users</h3>
<span class="block-count">${blockedUsers.length} blocked</span>
</div>
<form class="block-add-form">
<input type="text"
class="block-input"
placeholder="Enter username to block"
autocomplete="off"
autocapitalize="none">
<button type="submit" class="btn btn-danger block-add-btn">Block</button>
</form>
<div class="blocked-list">
${blockedUsers.length > 0 ? blockedUsers.map(username => `
<div class="blocked-item" data-username="${username}">
<span class="blocked-username">@${username}</span>
<button class="btn btn-small btn-secondary unblock-btn" data-username="${username}">
Unblock
</button>
</div>
`).join('') : `
<div class="blocked-empty">
<p>No blocked users</p>
</div>
`}
</div>
${blockedUsers.length > 0 ? `
<div class="block-actions">
<button class="btn btn-secondary btn-small clear-all-btn">Unblock All</button>
</div>
` : ''}
`);
}
bindEvents() {
this.on(this, 'submit', this.handleSubmit);
this.on(this, 'click', this.handleClick);
window.addEventListener('rantii:block-change', this.blockChangeHandler);
}
onDisconnected() {
window.removeEventListener('rantii:block-change', this.blockChangeHandler);
}
handleSubmit(e) {
e.preventDefault();
const input = this.$('.block-input');
if (!input) return;
const username = input.value.trim().replace(/^@/, '');
if (!username) return;
const block = this.getBlock();
if (block?.isBlocked(username)) {
window.app?.toast?.error(`@${username} is already blocked`);
return;
}
if (block?.block(username)) {
window.app?.toast?.success(`Blocked @${username}`);
input.value = '';
}
}
handleClick(e) {
const unblockBtn = e.target.closest('.unblock-btn');
const clearAllBtn = e.target.closest('.clear-all-btn');
if (unblockBtn) {
e.preventDefault();
e.stopPropagation();
const username = unblockBtn.dataset.username;
if (username && this.getBlock()?.unblock(username)) {
window.app?.toast?.success(`Unblocked @${username}`);
}
return;
}
if (clearAllBtn) {
e.preventDefault();
e.stopPropagation();
if (confirm('Unblock all users?')) {
this.getBlock()?.clear();
window.app?.toast?.success('All users unblocked');
}
}
}
getBlock() {
return window.app?.block;
}
}
customElements.define('blocked-users-manager', BlockedUsersManager);
export { BlockedUsersManager };

View File

@ -13,10 +13,21 @@ class NotificationList extends BaseComponent {
this.notifications = [];
this.isLoading = false;
this.unreadCount = 0;
this.blockChangeHandler = () => this.render();
this.render();
this.bindEvents();
}
getBlock() {
return window.app?.block;
}
getFilteredNotifications() {
const block = this.getBlock();
if (!block) return this.notifications;
return block.filterNotifications(this.notifications);
}
async load() {
if (!this.isLoggedIn()) {
this.render();
@ -62,7 +73,9 @@ class NotificationList extends BaseComponent {
return;
}
if (this.notifications.length === 0) {
const filteredNotifications = this.getFilteredNotifications();
if (filteredNotifications.length === 0) {
this.setHtml(`
<div class="notification-empty">
<svg viewBox="0 0 24 24" width="48" height="48">
@ -82,7 +95,7 @@ class NotificationList extends BaseComponent {
` : ''}
</header>
<div class="notification-items">
${this.notifications.map(notif => `
${filteredNotifications.map(notif => `
<notification-item
data-notif='${JSON.stringify(notif).replace(/'/g, '&#39;')}'>
</notification-item>
@ -90,24 +103,21 @@ class NotificationList extends BaseComponent {
</div>
`);
this.initNotificationItems();
this.initNotificationItems(filteredNotifications);
}
initNotificationItems() {
initNotificationItems(filteredNotifications) {
const items = this.$$('notification-item');
items.forEach(item => {
const notifData = item.dataset.notif;
if (notifData) {
try {
const notif = JSON.parse(notifData.replace(/&#39;/g, "'"));
item.setNotification(notif);
} catch (e) {}
items.forEach((item, index) => {
if (filteredNotifications[index]) {
item.setNotification(filteredNotifications[index]);
}
});
}
bindEvents() {
this.on(this, 'click', this.handleClick);
window.addEventListener('rantii:block-change', this.blockChangeHandler);
}
handleClick(e) {
@ -139,6 +149,7 @@ class NotificationList extends BaseComponent {
onDisconnected() {
this.isLoading = false;
window.removeEventListener('rantii:block-change', this.blockChangeHandler);
}
getUnreadCount() {

View File

@ -18,10 +18,27 @@ class RantDetail extends BaseComponent {
this.rantData = null;
this.comments = [];
this.isLoading = false;
this.blockChangeHandler = () => this.render();
this.render();
this.bindEvents();
}
getBlock() {
return window.app?.block;
}
getFilteredComments() {
const block = this.getBlock();
if (!block) return this.comments;
return block.filterComments(this.comments);
}
isRantBlocked() {
const block = this.getBlock();
if (!block || !this.rantData) return false;
return block.isBlocked(this.rantData.user_username);
}
async load(rantId) {
this.setAttr('rant-id', rantId);
this.isLoading = true;
@ -69,6 +86,30 @@ class RantDetail extends BaseComponent {
return;
}
if (this.isRantBlocked()) {
this.setHtml(`
<div class="rant-detail-blocked">
<header class="detail-header">
<button class="back-btn" aria-label="Go back">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
</button>
<h1 class="detail-title">Blocked User</h1>
</header>
<div class="blocked-message">
<svg viewBox="0 0 24 24" width="48" height="48">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/>
</svg>
<p>This rant is from a blocked user</p>
<p class="blocked-username">@${this.rantData.user_username}</p>
<button class="btn btn-secondary unblock-btn" data-username="${this.rantData.user_username}">Unblock User</button>
</div>
</div>
`);
return;
}
const rant = this.rantData;
const hasImage = rant.attached_image && typeof rant.attached_image === 'object';
const imageUrl = hasImage ? buildDevrantImageUrl(rant.attached_image.url) : null;
@ -131,7 +172,7 @@ class RantDetail extends BaseComponent {
<section class="comments-section">
<comment-form rant-id="${rant.id}"></comment-form>
<div class="comments-list">
${this.comments.map(comment => `
${this.getFilteredComments().map(comment => `
<comment-item
comment-id="${comment.id}"
data-comment='${JSON.stringify(comment).replace(/'/g, '&#39;')}'>
@ -155,14 +196,11 @@ class RantDetail extends BaseComponent {
}
initComments() {
const filteredComments = this.getFilteredComments();
const commentItems = this.$$('comment-item');
commentItems.forEach(item => {
const commentData = item.dataset.comment;
if (commentData) {
try {
const comment = JSON.parse(commentData.replace(/&#39;/g, "'"));
item.setComment(comment);
} catch (e) {}
commentItems.forEach((item, index) => {
if (filteredComments[index]) {
item.setComment(filteredComments[index]);
}
});
}
@ -171,6 +209,11 @@ class RantDetail extends BaseComponent {
this.on(this, 'click', this.handleClick);
this.on(this, 'vote', this.handleVote);
this.on(this, 'comment-posted', this.handleCommentPosted);
window.addEventListener('rantii:block-change', this.blockChangeHandler);
}
onDisconnected() {
window.removeEventListener('rantii:block-change', this.blockChangeHandler);
}
handleClick(e) {
@ -178,6 +221,7 @@ class RantDetail extends BaseComponent {
const username = e.target.closest('.author-username');
const avatar = e.target.closest('user-avatar');
const tag = e.target.closest('.tag');
const unblockBtn = e.target.closest('.unblock-btn');
if (backBtn) {
e.preventDefault();
@ -185,6 +229,17 @@ class RantDetail extends BaseComponent {
return;
}
if (unblockBtn) {
e.preventDefault();
e.stopPropagation();
const usernameToUnblock = unblockBtn.dataset.username;
if (usernameToUnblock) {
this.getBlock()?.unblock(usernameToUnblock);
window.app?.toast?.success(`Unblocked @${usernameToUnblock}`);
}
return;
}
if (username || avatar) {
this.getRouter()?.goToUser(this.rantData.user_username);
return;

View File

@ -20,11 +20,22 @@ class RantFeed extends BaseComponent {
this.hasMore = true;
this.sort = this.getAttr('sort') || 'recent';
this.feedType = this.getAttr('feed-type') || 'rants';
this.blockChangeHandler = () => this.render();
this.render();
this.bindEvents();
}
getBlock() {
return window.app?.block;
}
getFilteredRants() {
const block = this.getBlock();
if (!block) return this.rants;
return block.filterRants(this.rants);
}
async load(reset = false) {
if (this.isLoading) return;
if (!reset && !this.hasMore) return;
@ -98,7 +109,9 @@ class RantFeed extends BaseComponent {
render() {
this.addClass('rant-feed');
if (this.rants.length === 0 && !this.isLoading) {
const filteredRants = this.getFilteredRants();
if (filteredRants.length === 0 && !this.isLoading) {
this.setHtml(`
<div class="feed-empty">
<p>No rants found</p>
@ -116,7 +129,7 @@ class RantFeed extends BaseComponent {
</div>
</div>
<div class="feed-list">
${this.rants.map(rant => `
${filteredRants.map(rant => `
<rant-card rant-id="${rant.id}"></rant-card>
`).join('')}
</div>
@ -132,14 +145,14 @@ class RantFeed extends BaseComponent {
` : ''}
`);
this.initRantCards();
this.initRantCards(filteredRants);
}
initRantCards() {
initRantCards(filteredRants) {
const cards = this.$$('rant-card');
cards.forEach((card, index) => {
if (this.rants[index]) {
card.setRant(this.rants[index]);
if (filteredRants[index]) {
card.setRant(filteredRants[index]);
}
});
}
@ -157,6 +170,7 @@ class RantFeed extends BaseComponent {
bindEvents() {
this.on(this, 'click', this.handleClick);
this.setupInfiniteScroll();
window.addEventListener('rantii:block-change', this.blockChangeHandler);
}
handleClick(e) {
@ -203,6 +217,7 @@ class RantFeed extends BaseComponent {
}
this.isLoading = false;
this.hasMore = true;
window.removeEventListener('rantii:block-change', this.blockChangeHandler);
}
onAttributeChanged(name, oldValue, newValue) {

View File

@ -17,10 +17,21 @@ class UserProfile extends BaseComponent {
this.profileData = null;
this.isLoading = false;
this.activeTab = 'rants';
this.blockChangeHandler = () => this.render();
this.render();
this.bindEvents();
}
getBlock() {
return window.app?.block;
}
isUserBlocked() {
const block = this.getBlock();
if (!block || !this.profileData) return false;
return block.isBlocked(this.profileData.username);
}
async load(username) {
if (!username) return;
@ -64,6 +75,8 @@ class UserProfile extends BaseComponent {
const profile = this.profileData;
const rants = profile.content?.content?.rants || [];
const comments = profile.content?.content?.comments || [];
const isBlocked = this.isUserBlocked();
const isOwnProfile = window.app?.auth?.getUsername()?.toLowerCase() === profile.username.toLowerCase();
this.addClass('user-profile');
@ -84,8 +97,21 @@ class UserProfile extends BaseComponent {
<div class="profile-info">
<h1 class="profile-username">${profile.username}</h1>
<div class="profile-score">+${profile.score}</div>
${!isOwnProfile ? `
<button class="btn btn-small ${isBlocked ? 'btn-secondary' : 'btn-danger'} block-user-btn" data-username="${profile.username}">
${isBlocked ? 'Unblock' : 'Block'}
</button>
` : ''}
</div>
</header>
${isBlocked ? `
<div class="profile-blocked-banner">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/>
</svg>
<span>This user is blocked</span>
</div>
` : ''}
<section class="profile-details">
${profile.about ? `
<div class="detail-item">
@ -179,13 +205,36 @@ class UserProfile extends BaseComponent {
bindEvents() {
this.on(this, 'click', this.handleClick);
window.addEventListener('rantii:block-change', this.blockChangeHandler);
}
onDisconnected() {
window.removeEventListener('rantii:block-change', this.blockChangeHandler);
}
handleClick(e) {
const blockBtn = e.target.closest('.block-user-btn');
const backBtn = e.target.closest('.back-btn');
const tab = e.target.closest('.tab');
const profileComment = e.target.closest('.profile-comment');
if (blockBtn) {
e.preventDefault();
e.stopPropagation();
const username = blockBtn.dataset.username;
if (username) {
const block = this.getBlock();
if (block?.isBlocked(username)) {
block.unblock(username);
window.app?.toast?.success(`Unblocked @${username}`);
} else {
block?.block(username);
window.app?.toast?.success(`Blocked @${username}`);
}
}
return;
}
if (backBtn) {
e.preventDefault();
window.history.back();

View File

@ -63,6 +63,10 @@ class SettingsPage extends BaseComponent {
<h2>Appearance</h2>
<theme-selector></theme-selector>
</section>
<section class="settings-section">
<h2>Blocked Users</h2>
<blocked-users-manager></blocked-users-manager>
</section>
<section class="settings-section">
<h2>About</h2>
<div class="about-info">

View File

@ -22,11 +22,27 @@ class AuthService {
username: authData.username
};
this.notifyAuthChange();
this.fetchAndUpdateUsername(authData);
return true;
}
return false;
}
async fetchAndUpdateUsername(authData) {
if (!authData.userId) return;
try {
const profileResult = await this.api.getProfile(authData.userId);
if (profileResult?.success && profileResult.profile?.username) {
this.currentUser.username = profileResult.profile.username;
if (authData.username !== profileResult.profile.username) {
authData.username = profileResult.profile.username;
this.storage.setAuth(authData);
}
this.notifyAuthChange();
}
} catch (e) {}
}
async login(username, password, remember = true) {
const result = await this.api.login(username, password);
if (result.success) {

95
js/services/block.js Normal file
View File

@ -0,0 +1,95 @@
class BlockService {
constructor(storageService) {
this.storage = storageService;
this.blockedUsers = new Set();
this.onBlockChange = null;
}
init() {
const blocked = this.storage.getBlockedUsers();
this.blockedUsers = new Set(blocked.map(u => u.toLowerCase()));
}
isBlocked(username) {
if (!username) return false;
return this.blockedUsers.has(username.toLowerCase());
}
block(username) {
if (!username) return false;
const normalized = username.toLowerCase();
if (this.blockedUsers.has(normalized)) return false;
this.blockedUsers.add(normalized);
this.save();
this.notifyChange();
return true;
}
unblock(username) {
if (!username) return false;
const normalized = username.toLowerCase();
if (!this.blockedUsers.has(normalized)) return false;
this.blockedUsers.delete(normalized);
this.save();
this.notifyChange();
return true;
}
toggle(username) {
if (this.isBlocked(username)) {
return this.unblock(username);
}
return this.block(username);
}
getBlockedUsers() {
return Array.from(this.blockedUsers).sort();
}
getBlockedCount() {
return this.blockedUsers.size;
}
clear() {
this.blockedUsers.clear();
this.save();
this.notifyChange();
}
save() {
this.storage.setBlockedUsers(Array.from(this.blockedUsers));
}
notifyChange() {
if (this.onBlockChange) {
this.onBlockChange(this.getBlockedUsers());
}
window.dispatchEvent(new CustomEvent('rantii:block-change', {
detail: { blockedUsers: this.getBlockedUsers() }
}));
}
setBlockChangeCallback(callback) {
this.onBlockChange = callback;
}
filterRants(rants) {
if (!rants || !Array.isArray(rants)) return [];
return rants.filter(rant => !this.isBlocked(rant.user_username));
}
filterComments(comments) {
if (!comments || !Array.isArray(comments)) return [];
return comments.filter(comment => !this.isBlocked(comment.user_username));
}
filterNotifications(notifications) {
if (!notifications || !Array.isArray(notifications)) return [];
return notifications.filter(notif => {
const username = notif.username?.name || notif.user_username?.name || notif.name;
return !this.isBlocked(username);
});
}
}
export { BlockService };

View File

@ -133,6 +133,37 @@ class StorageService {
return this.remove(`draft_comment_${rantId}`);
}
getBlockedUsers() {
return this.get('blocked_users', []);
}
setBlockedUsers(users) {
return this.set('blocked_users', users);
}
addBlockedUser(username) {
const blocked = this.getBlockedUsers();
const normalized = username.toLowerCase();
if (!blocked.includes(normalized)) {
blocked.push(normalized);
return this.setBlockedUsers(blocked);
}
return true;
}
removeBlockedUser(username) {
const blocked = this.getBlockedUsers();
const normalized = username.toLowerCase();
const filtered = blocked.filter(u => u !== normalized);
return this.setBlockedUsers(filtered);
}
isUserBlocked(username) {
if (!username) return false;
const blocked = this.getBlockedUsers();
return blocked.includes(username.toLowerCase());
}
getCachedProfile(userId) {
return this.get(`profile_cache_${userId}`, null);
}