Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -113,6 +115,7 @@ fun HistoryScreen(
onPlay = onPickFile,
onRestore = vm::restore,
onHide = vm::hide,
onRemove = vm::remove,
onOpenTitle = onOpenCollection,
onRetry = vm::retry,
onBack = onBack,
Expand Down Expand Up @@ -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 },
)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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() }
Expand All @@ -480,6 +488,7 @@ private fun NameDialog(
.fillMaxWidth()
.focusRequester(first),
)
extra()
state.dialogError?.let { StatusLine(it.text, tone = StatusTone.Down) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Binary file modified android-tv/app/src/test/screenshots/history_phone800.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified android-tv/app/src/test/screenshots/history_phone915.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified android-tv/app/src/test/screenshots/history_tv.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion crates/iris-api/src/routes/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -107,7 +110,7 @@ pub(crate) async fn create(
) -> ApiResult<Json<Vec<ProfileView>>> {
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()
{
Expand Down
113 changes: 107 additions & 6 deletions crates/iris-db/src/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<User>, sqlx::Error> {
let owner: Uuid = account.into();
let id = Uuid::new_v4();
Expand All @@ -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),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/iris-api/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ export type MoodBoard = components['schemas']['MoodBoard'];
export type MoodResults = components['schemas']['MoodResults'];
export const profiles = {
list: () => api.get<ProfileView[]>('/me/profiles'),
create: (name: string) => api.post<ProfileView[]>('/me/profiles', { name }),
create: (name: string, copy_history: boolean) => api.post<ProfileView[]>('/me/profiles', { name, copy_history }),
remove: (id: string) => api.delete<void>(`/me/profiles/${seg(id)}`),
switchTo: (profile_id: string) => api.post<User>('/auth/switch-profile', { profile_id })
};
Expand Down
4 changes: 4 additions & 0 deletions web/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
6 changes: 5 additions & 1 deletion web/src/lib/account/AccountPage.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading