diff --git a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx index 7483b7a..f729080 100644 --- a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx +++ b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx @@ -30,6 +30,7 @@ import { deleteChannel, toggleGlobalChannelNotifs, editStreamInfo, + changeUsername, } from '@/lib/form/actions'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; @@ -74,6 +75,7 @@ interface ChannelSettingsClientProps { followers: (Follow & { user: { id: string; slack_id: string } })[]; followerPersonalChannels: (Channel | null)[]; is247: boolean; + nameLastChanged: Date | null; }; isOwner: boolean; currentUser: User; @@ -112,6 +114,32 @@ export default function ChannelSettingsClient({ } }, []); + const handleUsernameChangeComplete = useCallback( + (result: any) => { + if (result?.success && result?.newUsername) { + toast.success('Username changed successfully! Redirecting...'); + router.push(`/settings/channel/${result.newUsername}?tab=${selTab}`); + } + }, + [router, selTab] + ); + + const getUsernameChangeCooldownInfo = () => { + if (!channel.nameLastChanged) { + return { canChange: true, daysRemaining: 0 }; + } + const daysSinceLastChange = Math.floor( + (Date.now() - new Date(channel.nameLastChanged).getTime()) / (1000 * 60 * 60 * 24) + ); + const cooldownDays = 30; + if (daysSinceLastChange >= cooldownDays) { + return { canChange: true, daysRemaining: 0 }; + } + return { canChange: false, daysRemaining: cooldownDays - daysSinceLastChange }; + }; + + const cooldownInfo = getUsernameChangeCooldownInfo(); + const copyStreamKey = async () => { if (streamKey) { await navigator.clipboard.writeText(streamKey); @@ -179,10 +207,10 @@ export default function ChannelSettingsClient({ -
+
c.channel)} + channelList={channelList.channels.map((c) => c.channel)} value={channel.name} onSelect={(value) => { if (value === 'create') { @@ -216,7 +244,7 @@ export default function ChannelSettingsClient({ Notifications - + Utilities @@ -242,7 +270,7 @@ export default function ChannelSettingsClient({ return (
- + {field.value && (
@@ -251,7 +279,9 @@ export default function ChannelSettingsClient({

Current profile picture

-

Click "Upload new image" to replace

+

+ Click "Upload new image" to replace +

)} - +
{ setIsUploading(true); @@ -293,19 +323,15 @@ export default function ChannelSettingsClient({ }} disabled={isUploading} /> - + {isUploading && ( -

- Uploading... -

+

Uploading...

)} - + {uploadError && ( -

- {uploadError} -

+

{uploadError}

)} - + {!field.value && !isUploading && !uploadError && (

Upload a profile picture for your channel. @@ -351,7 +377,8 @@ export default function ChannelSettingsClient({

- Mark this channel as always live. It will disable notifications on #hctv-streams. + Mark this channel as always live. It will disable notifications on + #hctv-streams.

), - } + }, ]} schemaName="updateChannelSettings" action={updateChannelSettings} @@ -371,6 +398,48 @@ export default function ChannelSettingsClient({ onActionComplete={handleChannelSettingsActionComplete} /> + + + {isPersonal && isOwner && ( +
+
+

Username

+
+

+ Your username is how others find and mention you on hctv. You can change it once + every 30 days. +

+ {!cooldownInfo.canChange && ( +
+

+ You can change your username again in {cooldownInfo.daysRemaining} day + {cooldownInfo.daysRemaining === 1 ? '' : 's'}. +

+
+ )} + +
+ )} + {isOwner && !isPersonal && ( <> @@ -441,7 +510,11 @@ export default function ChannelSettingsClient({ onClick={() => setKeyVisible(!keyVisible)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" > - {keyVisible ? : } + {keyVisible ? ( + + ) : ( + + )}
@@ -486,7 +563,11 @@ export default function ChannelSettingsClient({ onClick={copyStreamUrl} disabled={!streamKey} > - {copied.streamUrl ? : } + {copied.streamUrl ? ( + + ) : ( + + )}
@@ -494,7 +575,7 @@ export default function ChannelSettingsClient({

Need help getting started? Check out our{' '} { - if (await confirm({ - title: 'Remove Manager', - description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`, - confirmText: 'Remove', - cancelText: 'Cancel', - })) { + if ( + await confirm({ + title: 'Remove Manager', + description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`, + confirmText: 'Remove', + cancelText: 'Cancel', + }) + ) { removeChannelManager(channel.id, manager.id); } }} @@ -727,7 +810,7 @@ export default function ChannelSettingsClient({

Chat overlay

- Add a 300x600 browser source with this and enjoy! + Add a 300x600 browser source with this and enjoy!

diff --git a/apps/web/src/components/app/ChatPanel/ChatPanel.tsx b/apps/web/src/components/app/ChatPanel/ChatPanel.tsx index 2300d76..66d68bb 100644 --- a/apps/web/src/components/app/ChatPanel/ChatPanel.tsx +++ b/apps/web/src/components/app/ChatPanel/ChatPanel.tsx @@ -248,11 +248,6 @@ export default function ChatPanel(props: Props) {
- {!props.isObsPanel && ( -
-

Live Chat

-
- )}
({ @@ -62,7 +68,7 @@ export function UniversalForm({ }, [fields, defaultValues]); type FormData = z.infer; - + const form = useForm({ resolver: zodResolver(schema as any), defaultValues: initialValues as FormData, @@ -86,8 +92,8 @@ export function UniversalForm({ control={form.control} name={field.name as Path} render={({ field: formField }) => ( - - {(field.type !== 'hidden' || field.label) && {field.label}} + + {field.type !== 'hidden' && field.label && {field.label}} {field.component ? ( field.component({ field: formField, ...field.componentProps }) @@ -115,17 +121,19 @@ export function UniversalForm({ /> )} - {field.description && {field.description}} - + {field.type !== 'hidden' && field.description && ( + {field.description} + )} + {field.type !== 'hidden' && } )} /> ))} -
+
{otherSubmitButton}
); -} \ No newline at end of file +} diff --git a/apps/web/src/lib/form/actions.ts b/apps/web/src/lib/form/actions.ts index 72fc38d..9155c7e 100644 --- a/apps/web/src/lib/form/actions.ts +++ b/apps/web/src/lib/form/actions.ts @@ -2,11 +2,12 @@ import { revalidatePath } from 'next/cache'; import { validateRequest } from '@/lib/auth/validate'; -import { prisma } from '@hctv/db'; +import { prisma, getRedisConnection } from '@hctv/db'; import zodVerify from '../zodVerify'; import { createBotSchema, createChannelSchema, + changeUsernameSchema, editBotSchema, onboardSchema, streamInfoEditSchema, @@ -343,7 +344,10 @@ export async function deleteChannel(channelId: string) { } if (!can(user, 'delete', 'channel', { channel })) { - return { success: false, error: 'Only channel owners can delete channels (personal channels cannot be deleted)' }; + return { + success: false, + error: 'Only channel owners can delete channels (personal channels cannot be deleted)', + }; } await prisma.channel.delete({ @@ -424,3 +428,125 @@ export async function editBot(prev: any, formData: FormData) { return { success: true, slug: updatedBot.slug }; } + +const USERNAME_CHANGE_COOLDOWN_DAYS = 30; + +export async function changeUsername(prev: any, formData: FormData) { + const { user } = await validateRequest(); + if (!user) { + return { success: false, error: 'Unauthorized' }; + } + + const zod = await zodVerify(changeUsernameSchema, formData); + if (!zod.success) { + return zod; + } + + const channel = await prisma.channel.findUnique({ + where: { id: zod.data.channelId }, + include: { + owner: true, + managers: true, + personalFor: true, + streamInfo: true, + streamKey: true, + }, + }); + + if (!channel) { + return { success: false, error: 'Channel not found' }; + } + + if (!channel.personalFor || channel.personalFor.id !== user.id) { + return { success: false, error: 'You can only change the username of your personal channel' }; + } + + if (channel.ownerId !== user.id) { + return { success: false, error: 'Unauthorized' }; + } + + if (channel.nameLastChanged) { + const daysSinceLastChange = Math.floor( + (Date.now() - new Date(channel.nameLastChanged).getTime()) / (1000 * 60 * 60 * 24) + ); + if (daysSinceLastChange < USERNAME_CHANGE_COOLDOWN_DAYS) { + const daysRemaining = USERNAME_CHANGE_COOLDOWN_DAYS - daysSinceLastChange; + return { + success: false, + error: `Please wait ${daysRemaining} more day${daysRemaining === 1 ? '' : 's'}.`, + }; + } + } + + const oldName = channel.name; + const newName = zod.data.newUsername; + + if (oldName === newName) { + return { success: false, error: 'New username must be different from the current one' }; + } + + const existingChannel = await prisma.channel.findUnique({ + where: { name: newName }, + }); + if (existingChannel) { + return { success: false, error: 'This username is already taken' }; + } + + const redis = getRedisConnection(); + + try { + await prisma.channel.update({ + where: { id: channel.id }, + data: { + name: newName, + nameLastChanged: process.env.NODE_ENV === 'production' ? new Date() : null, + }, + }); + + if (channel.streamInfo.length > 0) { + await prisma.streamInfo.updateMany({ + where: { channelId: channel.id }, + data: { username: newName }, + }); + } + + if (channel.streamKey) { + const oldStreamKey = `streamKey:${oldName}`; + const newStreamKey = `streamKey:${newName}`; + if (await redis.exists(oldStreamKey)) { + await redis.rename(oldStreamKey, newStreamKey); + } + } + + const oldHistoryKey = `chat:history:${oldName}`; + const newHistoryKey = `chat:history:${newName}`; + if (await redis.exists(oldHistoryKey)) { + const messagesWithScores = await redis.zrange(oldHistoryKey, 0, -1, 'WITHSCORES'); + if (messagesWithScores.length > 0) { + const args: (string | number)[] = []; + for (let i = 0; i < messagesWithScores.length; i += 2) { + const msgStr = messagesWithScores[i]; + const score = messagesWithScores[i + 1]; + try { + const msg = JSON.parse(msgStr); + msg.user.username = newName; + args.push(score, JSON.stringify(msg)); + } catch { + args.push(score, msgStr); + } + } + await redis.zadd(newHistoryKey, ...args); + } + await redis.del(oldHistoryKey); + } + + revalidatePath(`/settings/channel/${newName}`); + revalidatePath(`/${oldName}`); + revalidatePath(`/${newName}`); + + return { success: true, newUsername: newName }; + } catch (error) { + console.error('Failed to change username:', error); + return { success: false, error: 'Failed to change username. Please try again.' }; + } +} diff --git a/apps/web/src/lib/form/zod.ts b/apps/web/src/lib/form/zod.ts index 5d314cb..492cf1a 100644 --- a/apps/web/src/lib/form/zod.ts +++ b/apps/web/src/lib/form/zod.ts @@ -50,3 +50,8 @@ export const editBotSchema = createBotSchema.and( from: z.string().min(1), }) ); + +export const changeUsernameSchema = z.object({ + channelId: z.string().min(1), + newUsername: username, +}); diff --git a/packages/db/prisma/migrations/20260131224649_add_name_last_changed_to_channel/migration.sql b/packages/db/prisma/migrations/20260131224649_add_name_last_changed_to_channel/migration.sql new file mode 100644 index 0000000..5534e66 --- /dev/null +++ b/packages/db/prisma/migrations/20260131224649_add_name_last_changed_to_channel/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Channel" ADD COLUMN "nameLastChanged" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 5bec0d6..3f79289 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -45,8 +45,9 @@ model Channel { description String @default("A hctv channel") pfpUrl String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + nameLastChanged DateTime? personalFor User? @relation("PersonalChannel")