Add full notification preferences system

Users can now control which in-app and email notifications they receive
from their Settings tab on their channel page.

Bell (in-app) preferences:
- New comment on my video (default: on)
- Reply to my comment (default: on)
- Comment liked (default: on)
- Video liked (default: on)
- New subscriber (default: on)
- New video from channels I follow (default: on)
- New post from channels I follow (default: on)
- New user registration — super admins only (default: on)

Email preferences (same set plus):
- Comment liked (default: off — too noisy)
- Video liked (default: off — too noisy)
- New post from channels I follow (default: off)
- My video finished processing (default: on)
- Weekly activity digest every Monday (default: on)
- New user registration — super admins only (default: on)

Implementation:
- Migration: notification_preferences JSON column on users table
- User::notificationPref($key) helper with typed defaults
- All existing notification classes updated to check prefs in via()
- 4 new notification classes: NewSubscriberNotification,
  VideoLikedNotification, NewPostNotification, WeeklyDigestNotification
- 8 new email views matching existing dark theme
- SendWeeklyDigest artisan command, scheduled every Monday 09:00
- NewSubscriberNotification wired into UserController::toggleSubscribe
- VideoLikedNotification wired into UserController::toggleLike
- NewPostNotification wired into PostController::store (to all subscribers)
- Bell renderer updated for new_subscriber, video_like, new_post types
- Preferences saved via AJAX (POST /settings/notifications) — instant
  toggle with automatic revert on failure

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-16 23:47:28 +03:00
1 parent 3fe167e33f
commit f8d13457fa
26 files changed
+808 -18

No files matched your search

+13 -6
View File
@@ -1226,20 +1226,27 @@
case 'new_comment': return actor + ' commented on your video ' + title + ':' + preview;
case 'new_reply': return actor + ' replied to your comment:' + preview;
case 'comment_like':return actor + ' liked your comment on ' + title;
case 'new_user': return '&#x1F389; ' + actor + ' just joined TAKEONE!';
default: return actor + ' uploaded a new video: ' + title;
case 'new_user': return '&#x1F389; ' + actor + ' just joined TAKEONE!';
case 'new_subscriber': return '&#x1F514; ' + actor + ' subscribed to your channel';
case 'video_like': return '&#x2764;&#xFE0F; ' + actor + ' liked your video ' + title;
case 'new_post': return '&#x1F4DD; ' + actor + ' posted something new';
default: return actor + ' uploaded a new video: ' + title;
}
}
function notifHref(d) {
if (d.type === 'new_user') return '/channel/' + encodeURIComponent(d.user_channel || '');
if (d.type === 'new_user') return '/channel/' + encodeURIComponent(d.user_channel || '');
if (d.type === 'new_subscriber') return '/channel/' + encodeURIComponent(d.actor_channel || '');
if (d.type === 'new_post') return '/channel/' + encodeURIComponent(d.author_channel || '');
return '/videos/' + escHtml(d.video_route_key || '');
}
function notifThumb(d) {
if (d.type === 'new_user') {
return d.user_avatar
? '<img class="yt-notif-thumb" src="' + escHtml(d.user_avatar) + '" alt="" loading="lazy" onerror="notifThumbFallback(this)" style="border-radius:50%">'
var avatarTypes = ['new_user', 'new_subscriber', 'new_post'];
if (avatarTypes.indexOf(d.type) !== -1) {
var av = d.user_avatar || d.actor_avatar || d.author_avatar || '';
return av
? '<img class="yt-notif-thumb" src="' + escHtml(av) + '" alt="" loading="lazy" onerror="notifThumbFallback(this)" style="border-radius:50%">'
: '<div class="yt-notif-thumb-placeholder"><i class="bi bi-person-circle"></i></div>';
}
return d.video_thumbnail