diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b7c065..3e36c7a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Profiles**: a shared account holds up to six profiles, each with its own history, watchlist and preferences. An account with several asks who is watching, on the web and the TV. The password and passkeys stay on the main - profile; a password change ends the sessions of every profile. + profile; a password change ends the sessions of every profile. A new profile + can start with the main profile's history and watchlist, and any title can be + removed from a history. - **Friends**: found by name, never by email, and asked; declining tells the other person nothing. Home says what friends play now and watched or added lately. What a profile watches reaches friends only once it shares it, and a diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HistoryScreen.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HistoryScreen.kt index aa5b40ff..7a33c5cc 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HistoryScreen.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HistoryScreen.kt @@ -1,5 +1,6 @@ package studio.kahn.iris.tv.ui.screens +import studio.kahn.iris.tv.ui.components.ConfirmDialog import studio.kahn.iris.tv.ui.components.ActionStyle import studio.kahn.iris.tv.ui.components.ActionSize import studio.kahn.iris.tv.ui.components.ActionButton @@ -79,6 +80,7 @@ data class HistoryActions( val onPlay: (infohash: String, fileIdx: Int) -> Unit = { _, _ -> }, val onRestore: (HistoryLine) -> Unit = {}, val onHide: (HistoryGroupUi) -> Unit = {}, + val onRemove: (HistoryGroupUi) -> Unit = {}, val onOpenTitle: (collectionId: String) -> Unit = {}, val onRetry: () -> Unit = {}, val onBack: () -> Unit = {}, @@ -113,6 +115,7 @@ fun HistoryScreen( onPlay = onPickFile, onRestore = vm::restore, onHide = vm::hide, + onRemove = vm::remove, onOpenTitle = onOpenCollection, onRetry = vm::retry, onBack = onBack, @@ -261,13 +264,36 @@ private fun HistoryGroupRow( LineCard(group, line, busy, actions, Modifier.padding(start = IrisSpace.s6).focusReturn(keys, "line:${line.key}")) } } - ActionButton( - "Hide from friends", - { actions.onHide(group) }, - style = ActionStyle.Secondary, - size = ActionSize.Small, - busy = "hide:${group.key}" in busy, - ) + Row(horizontalArrangement = Arrangement.spacedBy(IrisSpace.s2)) { + ActionButton( + "Hide from friends", + { actions.onHide(group) }, + style = ActionStyle.Secondary, + size = ActionSize.Small, + busy = "hide:${group.key}" in busy, + ) + var asking by remember { mutableStateOf(false) } + ActionButton( + "Remove", + { asking = true }, + style = ActionStyle.Secondary, + size = ActionSize.Small, + busy = "remove:${group.key}" in busy, + ) + if (asking) { + ConfirmDialog( + eyebrow = "Watch history", + title = "Remove ${group.title} from your history?", + body = "Your progress on it goes, and it leaves Continue watching. The title stays in the library.", + confirmLabel = "Remove from history", + onConfirm = { + asking = false + actions.onRemove(group) + }, + onCancel = { asking = false }, + ) + } + } } } } diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/SettingsScreen.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/SettingsScreen.kt index 47203de8..8010d8aa 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/SettingsScreen.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/SettingsScreen.kt @@ -1,5 +1,6 @@ package studio.kahn.iris.tv.ui.screens +import studio.kahn.iris.tv.ui.components.Pill import java.time.ZonedDateTime import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusGroup @@ -406,7 +407,13 @@ private fun SettingsDialogs(state: SettingsUiState, actions: SettingsActions) { onPick = actions.onSaveSubtitles, onDismiss = actions.onCloseDialog, ) - SettingsDialog.AddProfile -> NameDialog("Profiles", "A new profile", "Name", "Add the profile", state, Busy.PROFILE, actions.onAddProfile, actions.onCloseDialog) + SettingsDialog.AddProfile -> { + var copy by remember { mutableStateOf(false) } + val main = state.profiles.valueOrNull?.firstOrNull { it.main }?.name + NameDialog("Profiles", "A new profile", "Name", "Add the profile", state, Busy.PROFILE, { actions.onAddProfile(it, copy) }, actions.onCloseDialog) { + if (main != null) Pill("Start with $main’s history and watchlist", selected = copy, onClick = { copy = !copy }) + } + } SettingsDialog.FindFriend -> NameDialog("Friends", "Find someone", "Their name", "Find", state, Busy.FRIENDS, actions.onFindFriends, actions.onCloseDialog) is SettingsDialog.RemoveProfile -> ConfirmDialog( eyebrow = "Profiles", @@ -457,6 +464,7 @@ private fun NameDialog( busy: String, onConfirm: (String) -> Unit, onCancel: () -> Unit, + extra: @Composable () -> Unit = {}, ) { var name by remember { mutableStateOf("") } val first = remember { FocusRequester() } @@ -480,6 +488,7 @@ private fun NameDialog( .fillMaxWidth() .focusRequester(first), ) + extra() state.dialogError?.let { StatusLine(it.text, tone = StatusTone.Down) } } } diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/library/HistoryViewModel.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/library/HistoryViewModel.kt index 34e0c95c..f9c44b61 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/library/HistoryViewModel.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/library/HistoryViewModel.kt @@ -91,6 +91,13 @@ class HistoryViewModel(private val container: AppContainer) : ViewModel() { play.value = null } + fun remove(group: HistoryGroupUi) { + actions.runSaying("remove:${group.key}", onSuccess = history::poke) { + group.lines.forEach { container.api().removeProgress(it.infohash, it.fileIdx) } + "${group.title} left your history." + } + } + fun hide(group: HistoryGroupUi) { actions.runSaying("hide:${group.key}") { container.api().hideFromFriends(HideRequest(infohash = group.lines.first().infohash)) diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsSections.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsSections.kt index 90e7241d..6247d088 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsSections.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsSections.kt @@ -91,7 +91,7 @@ class SettingsActions( val onPair: (code: String, label: String) -> Unit = { _, _ -> }, val onStopWaiting: () -> Unit = {}, val onRevoke: (DeviceView) -> Unit = {}, - val onAddProfile: (String) -> Unit = {}, + val onAddProfile: (String, Boolean) -> Unit = { _, _ -> }, val onSwitchProfile: (ProfileView) -> Unit = {}, val onRemoveProfile: (ProfileView) -> Unit = {}, val onFindFriends: (String) -> Unit = {}, diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsViewModel.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsViewModel.kt index f6dff1c6..0fc4b1e2 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsViewModel.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/settings/SettingsViewModel.kt @@ -456,14 +456,14 @@ class SettingsViewModel(private val container: AppContainer) : ViewModel() { } } - fun addProfile(name: String) { + fun addProfile(name: String, copyHistory: Boolean) { val clean = name.trim() if (clean.isEmpty()) { mutable.update { it.copy(dialogError = DialogError("Enter a name.")) } return } act(Busy.PROFILE, SettingsSection.Profiles, inDialog = true) { api -> - val next = api.createProfile(CreateProfileRequest(name = clean)) + val next = api.createProfile(CreateProfileRequest(name = clean, copyHistory = copyHistory)) mutable.update { it.copy(profiles = Loadable.Ready(next)) } "Profile $clean added." } diff --git a/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/SettingsScreenshots.kt b/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/SettingsScreenshots.kt index 50af74c2..d6e4cec0 100644 --- a/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/SettingsScreenshots.kt +++ b/android-tv/app/src/test/java/studio/kahn/iris/tv/screenshot/SettingsScreenshots.kt @@ -105,6 +105,11 @@ class SettingsScreenshots { Settings(ready.copy(profiles = Loadable.Ready(family)), SettingsSection.Profiles) } + @Test + fun addProfile() = shots.snap("settings_profiles_add") { + Settings(ready.copy(profiles = Loadable.Ready(family), dialog = SettingsDialog.AddProfile), SettingsSection.Profiles) + } + @Test fun friends() = shots.snapEverySize("settings_friends") { Settings(ready.copy(friends = Loadable.Ready(friends), found = listOf(PersonView(UUID(0, 12), "Vincent"))), SettingsSection.Friends) diff --git a/android-tv/app/src/test/screenshots/history_phone800.png b/android-tv/app/src/test/screenshots/history_phone800.png index ece6d1a4..080ec373 100644 Binary files a/android-tv/app/src/test/screenshots/history_phone800.png and b/android-tv/app/src/test/screenshots/history_phone800.png differ diff --git a/android-tv/app/src/test/screenshots/history_phone915.png b/android-tv/app/src/test/screenshots/history_phone915.png index fc822699..28aafe8c 100644 Binary files a/android-tv/app/src/test/screenshots/history_phone915.png and b/android-tv/app/src/test/screenshots/history_phone915.png differ diff --git a/android-tv/app/src/test/screenshots/history_tv.png b/android-tv/app/src/test/screenshots/history_tv.png index d71b1f7b..bdccaef7 100644 Binary files a/android-tv/app/src/test/screenshots/history_tv.png and b/android-tv/app/src/test/screenshots/history_tv.png differ diff --git a/android-tv/app/src/test/screenshots/settings_profiles_add.png b/android-tv/app/src/test/screenshots/settings_profiles_add.png new file mode 100644 index 00000000..4056b846 Binary files /dev/null and b/android-tv/app/src/test/screenshots/settings_profiles_add.png differ diff --git a/crates/iris-api/src/routes/profiles.rs b/crates/iris-api/src/routes/profiles.rs index 9cbdbfd5..cd58122e 100644 --- a/crates/iris-api/src/routes/profiles.rs +++ b/crates/iris-api/src/routes/profiles.rs @@ -36,6 +36,9 @@ pub struct ProfileView { #[derive(Debug, Deserialize, ToSchema)] pub struct CreateProfileRequest { pub name: String, + /// Start from a copy of the main profile's history and watchlist. + #[serde(default)] + pub copy_history: bool, } #[derive(Debug, Deserialize, ToSchema)] @@ -107,7 +110,7 @@ pub(crate) async fn create( ) -> ApiResult>> { let name = crate::routes::me::checked_display_name(&body.name)?; let me = current(&state, &user).await?; - if iris_db::users::create_profile(state.db(), me.account(), name) + if iris_db::users::create_profile(state.db(), me.account(), name, body.copy_history) .await? .is_none() { diff --git a/crates/iris-db/src/users.rs b/crates/iris-db/src/users.rs index 4d8390ac..cb35143b 100644 --- a/crates/iris-db/src/users.rs +++ b/crates/iris-db/src/users.rs @@ -89,11 +89,14 @@ pub const MAX_PROFILES: usize = 6; /// A new profile under `account`, `None` once it holds [`MAX_PROFILES`]. Its /// email only fills the unique column and its hash verifies nothing: a profile -/// is entered from its account, never signed in to. +/// is entered from its account, never signed in to. With `copy_history` it +/// starts from a copy of the account's watched titles, resume points, watchlist +/// and what was dismissed from them; each then lives its own life. pub async fn create_profile( pool: &SqlitePool, account: UserId, name: &str, + copy_history: bool, ) -> Result, sqlx::Error> { let owner: Uuid = account.into(); let id = Uuid::new_v4(); @@ -119,6 +122,32 @@ pub async fn create_profile( .bind(owner) .execute(&mut *tx) .await?; + if copy_history { + for sql in [ + "INSERT INTO playback_progress (user_id, infohash, file_idx, position_seconds, \ + duration_seconds, audio_track_idx, subtitle_track_idx, completed, last_watched_at) \ + SELECT ?1, infohash, file_idx, position_seconds, duration_seconds, audio_track_idx, \ + subtitle_track_idx, completed, last_watched_at \ + FROM playback_progress WHERE user_id = ?2", + "INSERT INTO series_follows (id, user_id, normalized_name, name, tmdb_id, \ + last_checked_at, last_visited_at, created_at) \ + SELECT randomblob(16), ?1, normalized_name, name, tmdb_id, last_checked_at, \ + last_visited_at, created_at \ + FROM series_follows WHERE user_id = ?2", + "INSERT INTO cw_dismissed (user_id, collection_id, dismissed_at) \ + SELECT ?1, collection_id, dismissed_at FROM cw_dismissed WHERE user_id = ?2", + "INSERT INTO ghost_dismissed (user_id, collection_id, dismissed_at) \ + SELECT ?1, collection_id, dismissed_at FROM ghost_dismissed WHERE user_id = ?2", + "INSERT INTO gone_release_dismissed (user_id, infohash, dismissed_at) \ + SELECT ?1, infohash, dismissed_at FROM gone_release_dismissed WHERE user_id = ?2", + ] { + sqlx::query(sql) + .bind(id) + .bind(owner) + .execute(&mut *tx) + .await?; + } + } tx.commit().await?; Ok(Some(User { id: UserId::from(id), @@ -471,7 +500,7 @@ mod tests { async fn a_profile_cannot_be_signed_in_to_and_stays_out_of_the_accounts() { let pool = migrated_pool().await; let account = make_user(&pool, "shared@t.test").await; - let profile = create_profile(&pool, account, "Muriel") + let profile = create_profile(&pool, account, "Muriel", false) .await .unwrap() .unwrap(); @@ -499,16 +528,26 @@ mod tests { let pool = migrated_pool().await; let account = make_user(&pool, "shared@t.test").await; for _ in 1..MAX_PROFILES { - assert!(create_profile(&pool, account, "p").await.unwrap().is_some()); + assert!( + create_profile(&pool, account, "p", false) + .await + .unwrap() + .is_some() + ); } - assert!(create_profile(&pool, account, "p").await.unwrap().is_none()); + assert!( + create_profile(&pool, account, "p", false) + .await + .unwrap() + .is_none() + ); } #[tokio::test] async fn a_password_change_ends_the_sessions_of_every_profile() { let pool = migrated_pool().await; let account = make_user(&pool, "shared@t.test").await; - let profile = create_profile(&pool, account, "Muriel") + let profile = create_profile(&pool, account, "Muriel", false) .await .unwrap() .unwrap(); @@ -539,7 +578,7 @@ mod tests { let pool = migrated_pool().await; let admin = make_user(&pool, "admin@t.test").await; let account = make_user(&pool, "shared@t.test").await; - let profile = create_profile(&pool, account, "Muriel") + let profile = create_profile(&pool, account, "Muriel", false) .await .unwrap() .unwrap(); @@ -556,6 +595,68 @@ mod tests { assert_eq!(UserId::from(owner), admin); } + #[tokio::test] + async fn a_profile_can_start_from_the_accounts_history_and_then_lives_its_own() { + let pool = migrated_pool().await; + let account = make_user(&pool, "shared@t.test").await; + insert_torrent(&pool, "cccc", account).await; + sqlx::query( + "INSERT INTO playback_progress (user_id, infohash, file_idx, position_seconds, last_watched_at) \ + VALUES (?1, 'cccc', 0, 120, ?2)", + ) + .bind(Uuid::from(account)) + .bind(Utc::now()) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO series_follows (id, user_id, normalized_name, name, created_at) \ + VALUES (?1, ?2, 'severance', 'Severance', ?3)", + ) + .bind(Uuid::new_v4()) + .bind(Uuid::from(account)) + .bind(Utc::now()) + .execute(&pool) + .await + .unwrap(); + let count = async |table: &'static str, user: UserId| -> i64 { + let sql = match table { + "progress" => "SELECT COUNT(*) FROM playback_progress WHERE user_id = ?1", + _ => "SELECT COUNT(*) FROM series_follows WHERE user_id = ?1", + }; + sqlx::query_as::<_, (i64,)>(sql) + .bind(Uuid::from(user)) + .fetch_one(&pool) + .await + .unwrap() + .0 + }; + + let empty = create_profile(&pool, account, "Muriel", false) + .await + .unwrap() + .unwrap(); + assert_eq!(count("progress", empty.id).await, 0); + + let copied = create_profile(&pool, account, "Lyros", true) + .await + .unwrap() + .unwrap(); + assert_eq!(count("progress", copied.id).await, 1); + assert_eq!(count("follows", copied.id).await, 1); + + sqlx::query("DELETE FROM playback_progress WHERE user_id = ?1") + .bind(Uuid::from(copied.id)) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + count("progress", account).await, + 1, + "the account keeps its own" + ); + } + #[tokio::test] async fn delete_missing_user_is_false() { let pool = migrated_pool().await; diff --git a/packages/iris-api/src/client.ts b/packages/iris-api/src/client.ts index 8cf59bfa..abcc640b 100644 --- a/packages/iris-api/src/client.ts +++ b/packages/iris-api/src/client.ts @@ -491,7 +491,7 @@ export type MoodBoard = components['schemas']['MoodBoard']; export type MoodResults = components['schemas']['MoodResults']; export const profiles = { list: () => api.get('/me/profiles'), - create: (name: string) => api.post('/me/profiles', { name }), + create: (name: string, copy_history: boolean) => api.post('/me/profiles', { name, copy_history }), remove: (id: string) => api.delete(`/me/profiles/${seg(id)}`), switchTo: (profile_id: string) => api.post('/auth/switch-profile', { profile_id }) }; diff --git a/web/openapi.json b/web/openapi.json index 6d941790..b092e66b 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -5551,6 +5551,10 @@ "name" ], "properties": { + "copy_history": { + "type": "boolean", + "description": "Start from a copy of the main profile's history and watchlist." + }, "name": { "type": "string" } diff --git a/web/src/lib/account/AccountPage.svelte.test.ts b/web/src/lib/account/AccountPage.svelte.test.ts index c4161154..7c4b10bf 100644 --- a/web/src/lib/account/AccountPage.svelte.test.ts +++ b/web/src/lib/account/AccountPage.svelte.test.ts @@ -106,7 +106,11 @@ describe('profiles', () => { await expect.element(section.getByText('Main profile · In use')).toBeVisible(); await section.getByLabelText('New profile').fill('Muriel'); await section.getByRole('button', { name: 'Add' }).click(); - await expect.poll(() => api.sent('POST', '/me/profiles')[0]?.body).toEqual({ name: 'Muriel' }); + await expect.poll(() => api.sent('POST', '/me/profiles')[0]?.body).toEqual({ name: 'Muriel', copy_history: false }); + await section.getByLabelText('New profile').fill('Lyros'); + await section.getByLabelText('Start with Léonard’s history and watchlist').click(); + await section.getByRole('button', { name: 'Add' }).click(); + await expect.poll(() => api.sent('POST', '/me/profiles')[1]?.body).toEqual({ name: 'Lyros', copy_history: true }); await section.getByRole('button', { name: 'Remove the profile Muriel' }).click(); await page.getByRole('button', { name: 'Remove the profile', exact: true }).click(); await expect.poll(() => api.sent('DELETE', '/me/profiles/u9')).toHaveLength(1); diff --git a/web/src/lib/account/Profiles.svelte b/web/src/lib/account/Profiles.svelte index b29204b7..e0ba3c24 100644 --- a/web/src/lib/account/Profiles.svelte +++ b/web/src/lib/account/Profiles.svelte @@ -23,6 +23,8 @@ const value = loadable(list); const g = new Gesture(); let name = $state(''); + let copy = $state(false); + const main = $derived(list.data?.find((p) => p.main)?.name); let field = $state(); function add(e: SubmitEvent) { @@ -30,10 +32,11 @@ const wanted = name.trim(); if (!wanted) return field?.focus(); return g.run( - () => profiles.create(wanted), + () => profiles.create(wanted, copy), (next) => { queryClient.setQueryData(KEYS.profiles, next); name = ''; + copy = false; ui.toast(`Profile ${wanted} added.`); }, 'add', @@ -99,6 +102,9 @@

{g.error}

+ {#if main} + + {/if} @@ -109,6 +115,13 @@ align-items: start; gap: var(--s-3); } + .copy { + display: inline-flex; + flex-basis: 100%; + align-items: center; + gap: var(--s-2); + min-height: var(--control-h); + } .add .field { flex: 1 1 12rem; } diff --git a/web/src/lib/history/HistoryList.svelte b/web/src/lib/history/HistoryList.svelte index a1aea053..4501168e 100644 --- a/web/src/lib/history/HistoryList.svelte +++ b/web/src/lib/history/HistoryList.svelte @@ -4,6 +4,7 @@ // in words. What is gone from disk says so and, when its release is known, can be // downloaded again (`onrestore`). Long histories stay light: rows out of view are not laid // out (content-visibility), yet all are in the page for find-in-page and screen readers. + import ConfirmDialog from '#lib/components/ConfirmDialog.svelte'; import Meter from '#lib/components/Meter.svelte'; import { pending, Gesture } from '#lib/gesture.svelte.ts'; import Icon from '#lib/components/Icon.svelte'; @@ -21,10 +22,12 @@ onrestore?: (it: Item) => Promise; /** Keep a title from friends; without it the list only reads. */ onhide?: (it: Item) => Promise; + /** Take a title out of this history; without it the list only reads. */ + onremove?: (items: readonly Item[]) => Promise; /** Titles lead to their collection page. */ collections?: boolean; } - let { groups, onrestore, onhide, collections = true }: Props = $props(); + let { groups, onrestore, onhide, onremove, collections = true }: Props = $props(); const g = new Gesture(); const label = (group: Group, it: Item) => (group.solo ? group.title : (whatWatched(it) ?? it.torrent_name)); @@ -90,19 +93,43 @@ {/if} - {#if onhide} - + {#if onhide || onremove} +
+ {#if onhide} + + {/if} + {#if onremove} + g.run(() => onremove(group.items), undefined, `remove:${group.key}`)} + /> + {/if} +
{/if} {/each}